fix(home): anchor track menus to their source cards

This commit is contained in:
zarzet committed 2026-10-05 01:55:07 +07:00
1 parent 4a6206c21c
commit ad3c9c0279
5 files changed
+240 -109

No files matched your search

+23 -7
View File
@@ -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<void>(
context: context,
context: itemContext,
// Featured cards can exceed the menu helper's page-size heuristic.
anchor: anchor,
builder: (menuContext) => MornyeContextMenu(
groups: [
[
+19 -14
View File
@@ -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),
),
],
),
+80 -78
View File
@@ -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<ExploreItem> 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,
),
),
],
],
),
),
),
),
+1 -1
View File
@@ -76,7 +76,7 @@ void main() {
items: items,
isFeatured: true,
),
onItemTap: (item) => opened = item,
onItemTap: (item, _) => opened = item,
),
),
),
+117 -9
View File
@@ -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<ExploreSection>? sections})
: _sections =
sections ??
const [
ExploreSection(
uri: 'example:featured',
title: 'Featured albums',
items: [],
),
];
final List<ExploreSection> _sections;
@override
ExploreState build() => const ExploreState(
sections: [
ExploreSection(
uri: 'example:featured',
title: 'Featured albums',
items: [],
),
],
);
ExploreState build() => ExploreState(sections: _sections);
}
class _History extends DownloadHistoryNotifier {