diff --git a/lib/providers/explore_provider.dart b/lib/providers/explore_provider.dart index 17e710ef..dfbcd1f5 100644 --- a/lib/providers/explore_provider.dart +++ b/lib/providers/explore_provider.dart @@ -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 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 json) { @@ -96,12 +106,14 @@ class ExploreSection { title: json['title'] as String? ?? '', items: items, isYTMusicQuickPicks: isQuickPicks, + isFeatured: json['layout'] == 'featured', ); } Map toJson() => { 'uri': uri, 'title': title, + 'layout': isFeatured ? 'featured' : 'shelf', 'items': items.map((i) => i.toJson()).toList(), }; } @@ -188,6 +200,7 @@ List> _normalizeExploreSectionsPayload( sections.add({ 'uri': section['uri']?.toString() ?? '', 'title': section['title']?.toString() ?? '', + 'layout': section['layout']?.toString() ?? 'shelf', 'items': items, }); } diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index 8200c054..88cab746 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -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'; diff --git a/lib/screens/home_tab_explore.dart b/lib/screens/home_tab_explore.dart index 5cfae7fe..5d44458e 100644 --- a/lib/screens/home_tab_explore.dart +++ b/lib/screens/home_tab_explore.dart @@ -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) { diff --git a/lib/widgets/explore_featured_section.dart b/lib/widgets/explore_featured_section.dart new file mode 100644 index 00000000..8533afd9 --- /dev/null +++ b/lib/widgets/explore_featured_section.dart @@ -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 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, + ), + ), + ], + ), + ), + ), + ); + }, + ), + ), + ], + ); + }, + ); + } +} diff --git a/test/explore_featured_section_test.dart b/test/explore_featured_section_test.dart new file mode 100644 index 00000000..2e7d432e --- /dev/null +++ b/test/explore_featured_section_test.dart @@ -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': []}).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); + }); + } +}