From ad3c9c02798d5f7cf670fdb76ae6b3538f6dc60c Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Mon, 5 Oct 2026 01:55:07 +0700 Subject: [PATCH] fix(home): anchor track menus to their source cards --- lib/screens/home_tab_explore.dart | 30 +++- lib/screens/home_tab_widgets.dart | 33 +++-- lib/widgets/explore_featured_section.dart | 158 +++++++++++----------- test/explore_featured_section_test.dart | 2 +- test/home_search_tabs_test.dart | 126 +++++++++++++++-- 5 files changed, 240 insertions(+), 109 deletions(-) diff --git a/lib/screens/home_tab_explore.dart b/lib/screens/home_tab_explore.dart index dbccb62c..8d3eed2b 100644 --- a/lib/screens/home_tab_explore.dart +++ b/lib/screens/home_tab_explore.dart @@ -84,7 +84,10 @@ extension _HomeTabExploreUI on _HomeTabState { ), index: index, staggerDelay: const Duration(milliseconds: 50), - child: _buildExploreItem(item, colorScheme), + child: Builder( + builder: (itemContext) => + _buildExploreItem(itemContext, item, colorScheme), + ), ); }, ), @@ -167,7 +170,11 @@ extension _HomeTabExploreUI on _HomeTabState { ); } - Widget _buildExploreItem(ExploreItem item, ColorScheme colorScheme) { + Widget _buildExploreItem( + BuildContext itemContext, + ExploreItem item, + ColorScheme colorScheme, + ) { final isArtist = item.type == 'artist'; final isTablet = MediaQuery.sizeOf(context).shortestSide >= 600; final cardSize = _exploreCardSize(context); @@ -177,7 +184,7 @@ extension _HomeTabExploreUI on _HomeTabState { button: true, label: context.l10n.a11yOpenItem(item.type, item.name), child: GestureDetector( - onTap: () => _navigateToExploreItem(item), + onTap: () => _navigateToExploreItem(item, itemContext), child: SizedBox( width: cardSize, child: Padding( @@ -305,12 +312,15 @@ extension _HomeTabExploreUI on _HomeTabState { ); } - void _navigateToExploreItem(ExploreItem item) async { + void _navigateToExploreItem( + ExploreItem item, + BuildContext itemContext, + ) async { final extensionId = _providerIdForExploreItem(item); switch (item.type) { case 'track': - _showTrackBottomSheet(item); + _showTrackBottomSheet(item, itemContext); return; case 'album': if (extensionId == null) { @@ -371,10 +381,16 @@ extension _HomeTabExploreUI on _HomeTabState { } } - void _showTrackBottomSheet(ExploreItem item) { + void _showTrackBottomSheet(ExploreItem item, BuildContext itemContext) { if (context.isMornye) { + final box = itemContext.findRenderObject(); + final anchor = box is RenderBox && box.hasSize + ? box.localToGlobal(Offset.zero) & box.size + : null; showMornyeContextMenu( - context: context, + context: itemContext, + // Featured cards can exceed the menu helper's page-size heuristic. + anchor: anchor, builder: (menuContext) => MornyeContextMenu( groups: [ [ diff --git a/lib/screens/home_tab_widgets.dart b/lib/screens/home_tab_widgets.dart index 9b0d8657..b107d856 100644 --- a/lib/screens/home_tab_widgets.dart +++ b/lib/screens/home_tab_widgets.dart @@ -1488,8 +1488,8 @@ class _QuickPicksPageView extends StatefulWidget { final ColorScheme colorScheme; final int itemsPerPage; final int totalPages; - final void Function(ExploreItem) onItemTap; - final void Function(ExploreItem) onItemMenu; + final void Function(ExploreItem, BuildContext) onItemTap; + final void Function(ExploreItem, BuildContext) onItemMenu; const _QuickPicksPageView({ required this.section, @@ -1557,7 +1557,10 @@ class _QuickPicksPageViewState extends State<_QuickPicksPageView> { key: ValueKey( 'quick-pick-${item.type}-${item.id}-${item.uri}', ), - child: _buildQuickPickItem(item), + child: Builder( + builder: (itemContext) => + _buildQuickPickItem(itemContext, item), + ), ); }), ); @@ -1592,9 +1595,9 @@ class _QuickPicksPageViewState extends State<_QuickPicksPageView> { ); } - Widget _buildQuickPickItem(ExploreItem item) { + Widget _buildQuickPickItem(BuildContext itemContext, ExploreItem item) { return InkWell( - onTap: () => widget.onItemTap(item), + onTap: () => widget.onItemTap(item, itemContext), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Row( @@ -1662,16 +1665,18 @@ class _QuickPicksPageViewState extends State<_QuickPicksPageView> { ], ), ), - IconButton( - tooltip: MaterialLocalizations.of(context).showMenuTooltip, - icon: Icon( - Icons.more_vert, - color: widget.colorScheme.onSurfaceVariant, - size: 20, + Builder( + builder: (buttonContext) => IconButton( + tooltip: MaterialLocalizations.of(context).showMenuTooltip, + icon: Icon( + Icons.more_vert, + color: widget.colorScheme.onSurfaceVariant, + size: 20, + ), + onPressed: () => widget.onItemMenu(item, buttonContext), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 48, minHeight: 48), ), - onPressed: () => widget.onItemMenu(item), - padding: EdgeInsets.zero, - constraints: const BoxConstraints(minWidth: 48, minHeight: 48), ), ], ), diff --git a/lib/widgets/explore_featured_section.dart b/lib/widgets/explore_featured_section.dart index a359062f..540262e1 100644 --- a/lib/widgets/explore_featured_section.dart +++ b/lib/widgets/explore_featured_section.dart @@ -6,7 +6,7 @@ import 'package:spotiflac_android/widgets/audio_quality_badges.dart'; /// Large editorial cards requested by a home-feed provider. class ExploreFeaturedSection extends StatelessWidget { final ExploreSection section; - final ValueChanged onItemTap; + final void Function(ExploreItem, BuildContext) onItemTap; const ExploreFeaturedSection({ super.key, @@ -67,93 +67,95 @@ class ExploreFeaturedSection extends StatelessWidget { ), ), ); - return Semantics( - button: true, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => onItemTap(item), - child: SizedBox( - width: width, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - height: headingHeight, - child: Text( - item.heading ?? '', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 12, - height: 1.5, - fontWeight: FontWeight.w600, - color: colors.primary, + return Builder( + builder: (itemContext) => Semantics( + button: true, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => onItemTap(item, itemContext), + child: SizedBox( + width: width, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + height: headingHeight, + child: Text( + item.heading ?? '', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12, + height: 1.5, + fontWeight: FontWeight.w600, + color: colors.primary, + ), ), ), - ), - SizedBox( - height: titleHeight, - child: ExplicitTrackTitle( - title: item.name, - explicit: item.explicit == true, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 22, - height: 1.4, + SizedBox( + height: titleHeight, + child: ExplicitTrackTitle( + title: item.name, + explicit: item.explicit == true, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 22, + height: 1.4, + ), ), ), - ), - SizedBox( - height: artistHeight, - child: Text( - item.artists, - maxLines: 1, + SizedBox( + height: artistHeight, + child: Text( + item.artists, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 16, + height: 1.5, + color: colors.onSurfaceVariant, + ), + ), + ), + const SizedBox(height: 8), + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: SizedBox( + width: width, + height: imageHeight, + child: image == null || image.isEmpty + ? fallback + : CachedCoverImage( + imageUrl: image, + // Decode one axis so wide editorial art + // keeps its ratio on high-density screens. + memCacheWidth: + (width * + MediaQuery.devicePixelRatioOf( + context, + )) + .ceil() + .clamp(320, 960), + fit: BoxFit.cover, + placeholder: (_, _) => fallback, + errorWidget: (_, _, _) => fallback, + ), + ), + ), + const SizedBox(height: 8), + Text( + item.description ?? '', + maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle( - fontSize: 16, + fontSize: 14, height: 1.5, color: colors.onSurfaceVariant, ), ), - ), - const SizedBox(height: 8), - ClipRRect( - borderRadius: BorderRadius.circular(12), - child: SizedBox( - width: width, - height: imageHeight, - child: image == null || image.isEmpty - ? fallback - : CachedCoverImage( - imageUrl: image, - // Decode one axis so wide editorial art - // keeps its ratio on high-density screens. - memCacheWidth: - (width * - MediaQuery.devicePixelRatioOf( - context, - )) - .ceil() - .clamp(320, 960), - fit: BoxFit.cover, - placeholder: (_, _) => fallback, - errorWidget: (_, _, _) => fallback, - ), - ), - ), - const SizedBox(height: 8), - Text( - item.description ?? '', - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 14, - height: 1.5, - color: colors.onSurfaceVariant, - ), - ), - ], + ], + ), ), ), ), diff --git a/test/explore_featured_section_test.dart b/test/explore_featured_section_test.dart index 8c067e86..bd39ed05 100644 --- a/test/explore_featured_section_test.dart +++ b/test/explore_featured_section_test.dart @@ -76,7 +76,7 @@ void main() { items: items, isFeatured: true, ), - onItemTap: (item) => opened = item, + onItemTap: (item, _) => opened = item, ), ), ), diff --git a/test/home_search_tabs_test.dart b/test/home_search_tabs_test.dart index 7595a371..99d6d0ce 100644 --- a/test/home_search_tabs_test.dart +++ b/test/home_search_tabs_test.dart @@ -16,8 +16,111 @@ import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/widgets/app_search_field.dart'; import 'package:spotiflac_android/widgets/lazy_tab_view.dart'; import 'package:spotiflac_android/widgets/mornye_chrome.dart'; +import 'package:spotiflac_android/widgets/mornye_context_menu.dart'; void main() { + for (final (size, layout) in [ + for (final layout in ['shelf', 'featured', 'quick-pick', 'quick-pick-menu']) + (const Size(430, 932), layout), + (const Size(1024, 768), 'featured'), + ]) { + testWidgets( + 'Home $layout menu follows its source after scrolling at $size', + (tester) async { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.pumpWidget( + ProviderScope( + overrides: [ + settingsProvider.overrideWith(_Settings.new), + extensionProvider.overrideWith(_Extensions.new), + exploreProvider.overrideWith( + () => _Explore( + sections: List.generate( + 6, + (index) => ExploreSection( + uri: 'example:section:$index', + title: 'Section $index', + isFeatured: layout == 'featured', + isYTMusicQuickPicks: layout.startsWith('quick-pick'), + items: [ + ExploreItem( + id: 'track-$index', + uri: 'example:track:$index', + type: 'track', + name: 'Feed track $index', + artists: '', + ), + ], + ), + ), + ), + ), + downloadHistoryProvider.overrideWith(_History.new), + recentAccessProvider.overrideWith(_Recent.new), + trackProvider.overrideWith(_Search.new), + ], + child: MaterialApp( + theme: MornyeTheme.build(Brightness.dark), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const HomeTab(mode: HomeTabMode.browse), + ), + ), + ); + await tester.pumpAndSettle(); + + for (final index in [0, 5]) { + final title = find.text('Feed track $index'); + if (index > 0) { + await tester.scrollUntilVisible( + title, + 300, + scrollable: find.byType(Scrollable).first, + ); + await tester.pumpAndSettle(); + } + final card = find + .ancestor( + of: title, + matching: layout.startsWith('quick-pick') + ? find.byType(InkWell) + : find.byType(GestureDetector), + ) + .first; + await Scrollable.ensureVisible(tester.element(card), alignment: 0.3); + await tester.pumpAndSettle(); + final control = layout == 'quick-pick-menu' + ? find.descendant(of: card, matching: find.byType(IconButton)) + : card; + final anchor = tester.getRect(control); + await tester.tap(control); + await tester.pumpAndSettle(); + + expect(find.text('Download'), findsOneWidget); + expect(find.text('Go to Album'), findsOneWidget); + final menu = tester.getRect(find.byType(MornyeContextMenu)); + expect( + menu.top, + anyOf( + closeTo(anchor.bottom + 8, 1), + closeTo(anchor.top - menu.height - 8, 1), + ), + ); + expect(menu.left, greaterThanOrEqualTo(16)); + expect(menu.right, lessThanOrEqualTo(size.width - 16)); + expect(menu.top, greaterThanOrEqualTo(16)); + expect(menu.bottom, lessThanOrEqualTo(size.height - 16)); + await tester.tapAt(const Offset(5, 5)); + await tester.pumpAndSettle(); + expect(find.byType(MornyeContextMenu), findsNothing); + } + expect(tester.takeException(), isNull); + }, + ); + } + testWidgets( 'Home keeps its feed while Search retains its query and results', (tester) async { @@ -321,16 +424,21 @@ class _Extensions extends ExtensionNotifier { } class _Explore extends ExploreNotifier { + _Explore({List? sections}) + : _sections = + sections ?? + const [ + ExploreSection( + uri: 'example:featured', + title: 'Featured albums', + items: [], + ), + ]; + + final List _sections; + @override - ExploreState build() => const ExploreState( - sections: [ - ExploreSection( - uri: 'example:featured', - title: 'Featured albums', - items: [], - ), - ], - ); + ExploreState build() => ExploreState(sections: _sections); } class _History extends DownloadHistoryNotifier {