mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-19 14:07:26 +02:00
fix(home): anchor track menus to their source cards
This commit is contained in:
1 parent
4a6206c21c
commit
ad3c9c0279
5 files changed
+240
-109
No files matched your search
@@ -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: [
|
||||
[
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -76,7 +76,7 @@ void main() {
|
||||
items: items,
|
||||
isFeatured: true,
|
||||
),
|
||||
onItemTap: (item) => opened = item,
|
||||
onItemTap: (item, _) => opened = item,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user