feat(home): add large featured cards for extension feeds

This commit is contained in:
zarzet
2026-09-21 18:38:04 +07:00
parent ed80d3e8bb
commit 5958fe2719
5 changed files with 278 additions and 0 deletions
+13
View File
@@ -18,6 +18,8 @@ class ExploreItem {
final String artists;
final String? description;
final String? coverUrl;
final String? featuredCoverUrl;
final String? heading;
final String? providerId;
final String? albumId;
final String? albumName;
@@ -32,6 +34,8 @@ class ExploreItem {
required this.artists,
this.description,
this.coverUrl,
this.featuredCoverUrl,
this.heading,
this.providerId,
this.albumId,
this.albumName,
@@ -48,6 +52,8 @@ class ExploreItem {
artists: json['artists'] as String? ?? '',
description: json['description'] as String?,
coverUrl: json['cover_url'] as String?,
featuredCoverUrl: json['featured_cover_url'] as String?,
heading: json['heading'] as String?,
providerId: json['provider_id'] as String?,
albumId: json['album_id'] as String?,
albumName: json['album_name'] as String?,
@@ -64,6 +70,8 @@ class ExploreItem {
'artists': artists,
'description': description,
'cover_url': coverUrl,
'featured_cover_url': featuredCoverUrl,
'heading': heading,
'provider_id': providerId,
'album_id': albumId,
'album_name': albumName,
@@ -77,12 +85,14 @@ class ExploreSection {
final String title;
final List<ExploreItem> items;
final bool isYTMusicQuickPicks;
final bool isFeatured;
const ExploreSection({
required this.uri,
required this.title,
required this.items,
this.isYTMusicQuickPicks = false,
this.isFeatured = false,
});
factory ExploreSection.fromJson(Map<String, dynamic> json) {
@@ -96,12 +106,14 @@ class ExploreSection {
title: json['title'] as String? ?? '',
items: items,
isYTMusicQuickPicks: isQuickPicks,
isFeatured: json['layout'] == 'featured',
);
}
Map<String, dynamic> toJson() => {
'uri': uri,
'title': title,
'layout': isFeatured ? 'featured' : 'shelf',
'items': items.map((i) => i.toJson()).toList(),
};
}
@@ -188,6 +200,7 @@ List<Map<String, Object?>> _normalizeExploreSectionsPayload(
sections.add({
'uri': section['uri']?.toString() ?? '',
'title': section['title']?.toString() ?? '',
'layout': section['layout']?.toString() ?? 'shelf',
'items': items,
});
}
+1
View File
@@ -24,6 +24,7 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/recent_access_provider.dart';
import 'package:spotiflac_android/providers/explore_provider.dart';
import 'package:spotiflac_android/widgets/explore_featured_section.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
import 'package:spotiflac_android/screens/album_screen.dart';
+6
View File
@@ -45,6 +45,12 @@ extension _HomeTabExploreUI on _HomeTabState {
}
Widget _buildExploreSection(ExploreSection section, ColorScheme colorScheme) {
if (section.isFeatured) {
return ExploreFeaturedSection(
section: section,
onItemTap: _navigateToExploreItem,
);
}
final sectionHeight = _exploreSectionHeight(context);
final isTablet = MediaQuery.sizeOf(context).shortestSide >= 600;
if (section.isYTMusicQuickPicks) {
+167
View File
@@ -0,0 +1,167 @@
import 'package:flutter/material.dart';
import 'package:spotiflac_android/providers/explore_provider.dart';
import 'package:spotiflac_android/widgets/cached_cover_image.dart';
/// Large editorial cards requested by a home-feed provider.
class ExploreFeaturedSection extends StatelessWidget {
final ExploreSection section;
final ValueChanged<ExploreItem> onItemTap;
const ExploreFeaturedSection({
super.key,
required this.section,
required this.onItemTap,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
final scaler = MediaQuery.textScalerOf(context);
return LayoutBuilder(
builder: (context, constraints) {
final width = (constraints.maxWidth - 48).clamp(160.0, 560.0);
final imageHeight = width / 1.5;
final headingHeight = scaler.scale(12) * 1.5;
final titleHeight = scaler.scale(22) * 1.4;
final artistHeight = scaler.scale(16) * 1.5;
final descriptionHeight = scaler.scale(14) * 1.5 * 2;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 20, 16, 12),
child: Text(
section.title,
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
SizedBox(
height:
headingHeight +
titleHeight +
artistHeight +
imageHeight +
descriptionHeight +
24,
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: section.items.length,
separatorBuilder: (_, _) => const SizedBox(width: 12),
itemBuilder: (context, index) {
final item = section.items[index];
final image = item.featuredCoverUrl?.isNotEmpty == true
? item.featuredCoverUrl
: item.coverUrl;
final fallback = ColoredBox(
color: colors.surfaceContainerHighest,
child: Center(
child: Icon(
Icons.album_outlined,
size: 48,
color: colors.onSurfaceVariant,
),
),
);
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,
),
),
),
SizedBox(
height: titleHeight,
child: Text(
item.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 22,
height: 1.4,
),
),
),
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: 14,
height: 1.5,
color: colors.onSurfaceVariant,
),
),
],
),
),
),
);
},
),
),
],
);
},
);
}
}
+91
View File
@@ -0,0 +1,91 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/providers/explore_provider.dart';
import 'package:spotiflac_android/widgets/explore_featured_section.dart';
void main() {
test('featured layout and artwork survive cache serialization', () {
final section = ExploreSection.fromJson({
'uri': 'featured',
'title': 'New',
'layout': 'featured',
'items': [
{
'id': 'collection-1',
'type': 'playlist',
'name': 'Featured playlist',
'cover_url': 'https://example.test/cover.jpg',
'featured_cover_url': 'https://example.test/banner.jpg',
'heading': 'Updated playlist',
'provider_id': 'example-provider',
},
],
});
final restored = ExploreSection.fromJson(section.toJson());
expect(restored.isFeatured, isTrue);
expect(restored.items.single.heading, 'Updated playlist');
expect(restored.items.single.featuredCoverUrl, endsWith('/banner.jpg'));
expect(restored.items.single.coverUrl, endsWith('/cover.jpg'));
expect(restored.items.single.providerId, 'example-provider');
expect(ExploreSection.fromJson({'items': <Object?>[]}).isFeatured, isFalse);
});
for (final (size, scale) in [
(const Size(390, 844), 1.0),
(const Size(320, 844), 2.0),
(const Size(1024, 768), 1.0),
]) {
testWidgets('featured cards scroll and open at $size, text scale $scale', (
tester,
) async {
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
ExploreItem? opened;
final items = List.generate(
4,
(index) => ExploreItem(
id: 'collection-$index',
uri: 'example:collection:$index',
type: 'album',
name: 'Featured $index',
artists: 'Example artist',
heading: 'New album',
description: 'An editorial description of the featured album.',
),
);
await tester.pumpWidget(
MaterialApp(
home: MediaQuery(
data: MediaQueryData(textScaler: TextScaler.linear(scale)),
child: Scaffold(
body: SingleChildScrollView(
child: ExploreFeaturedSection(
section: ExploreSection(
uri: 'featured',
title: 'New',
items: items,
isFeatured: true,
),
onItemTap: (item) => opened = item,
),
),
),
),
),
);
expect(tester.takeException(), isNull);
final card = find.ancestor(
of: find.text('Featured 0'),
matching: find.byType(GestureDetector),
);
expect(tester.getSize(card).width, greaterThan(250));
await tester.tap(find.text('Featured 0'));
expect(opened?.id, 'collection-0');
await tester.drag(find.byType(ListView), const Offset(-600, 0));
await tester.pumpAndSettle();
expect(find.text('Featured 2'), findsOneWidget);
expect(tester.takeException(), isNull);
});
}
}