diff --git a/lib/pages/recommend/recommend_page.dart b/lib/pages/recommend/recommend_page.dart index e56b9b9..3142f0e 100644 --- a/lib/pages/recommend/recommend_page.dart +++ b/lib/pages/recommend/recommend_page.dart @@ -15,6 +15,7 @@ import '../player/open_player.dart'; import '../player/player_view.dart'; import 'recommend_types.dart'; import 'widgets/all_dramas_filter_grid.dart'; +import 'widgets/playlist_detail_page.dart'; import 'widgets/recommend_sections_view.dart'; import 'widgets/recommend_top_nav.dart'; @@ -243,7 +244,41 @@ class _RecommendPageState extends State { } void _handleMore(RecommendSectionKind section) { - _selectTab(RecommendInnerTab.allDramas); + final l10n = AppLocalizations.of(context)!; + final copy = _sectionCopy(l10n, section); + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => PlaylistDetailPage( + title: copy.$1, + subtitle: copy.$2, + dramas: recommendSectionDramas( + kind: section, + source: widget.feedController.dramas, + categories: context.read(), + ), + ), + ), + ); + } + + (String, String) _sectionCopy( + AppLocalizations l10n, + RecommendSectionKind section, + ) { + return switch (section) { + RecommendSectionKind.hits => ( + l10n.weeklyHitsTitle, + l10n.weeklyHitsSubtitle, + ), + RecommendSectionKind.newReleases => ( + l10n.weeklyNewTitle, + l10n.weeklyNewSubtitle, + ), + RecommendSectionKind.costume => ( + l10n.costumeAwardsTitle, + l10n.costumeAwardsSubtitle, + ), + }; } Future _openDrama(Drama drama, {String? episodeId}) async { diff --git a/lib/pages/recommend/widgets/playlist_detail_page.dart b/lib/pages/recommend/widgets/playlist_detail_page.dart new file mode 100644 index 0000000..3eb0dbc --- /dev/null +++ b/lib/pages/recommend/widgets/playlist_detail_page.dart @@ -0,0 +1,237 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../../../core/data/models/drama.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_dimens.dart'; +import '../../../core/theme/app_theme.dart'; +import '../../../core/widgets/remote_image.dart'; +import '../../home/widgets/home_feed_card.dart'; +import '../../player/open_player.dart'; + +const double _collageHeight = 188; +const int _collageCoverCount = 5; +const double _pageHorizontal = 14; +const double _gridGap = 10; +const double _sprocketBand = 22; +const Color _filmBody = Color(0xFF161311); +const Color _filmHole = Color(0xFFE6D3BE); + +/// 剧单「更多」:顶部拼图、与剧单一致的标题描述,以及两列卡片。 +class PlaylistDetailPage extends StatelessWidget { + const PlaylistDetailPage({ + super.key, + required this.title, + required this.subtitle, + required this.dramas, + }); + + final String title; + final String subtitle; + final List dramas; + + @override + Widget build(BuildContext context) { + final width = MediaQuery.sizeOf(context).width; + final itemWidth = (width - _pageHorizontal * 2 - _gridGap) / 2; + final itemExtent = + itemWidth / AppDimens.cardCoverAspectRatio + AppDimens.homeCardBodyExtent; + return Scaffold( + key: const Key('playlist-detail-page'), + backgroundColor: AppColors.ink, + body: DecoratedBox( + decoration: const BoxDecoration(gradient: AppTheme.pageGradient), + child: SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + IconButton( + key: const Key('playlist-detail-back'), + onPressed: () => Navigator.of(context).maybePop(), + icon: const Icon(Icons.chevron_left_rounded), + color: AppColors.textPrimary, + ), + Expanded(child: _body(itemExtent)), + ], + ), + ), + ), + ); + } + + Widget _body(double itemExtent) { + return CustomScrollView( + key: const Key('playlist-detail-scroll'), + slivers: [ + SliverPadding( + padding: const EdgeInsets.fromLTRB( + _pageHorizontal, + 0, + _pageHorizontal, + 12, + ), + sliver: SliverToBoxAdapter(child: _PlaylistCollage(dramas: dramas)), + ), + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: _pageHorizontal), + sliver: SliverToBoxAdapter(child: _heading()), + ), + const SliverToBoxAdapter(child: SizedBox(height: 16)), + SliverPadding( + padding: const EdgeInsets.fromLTRB( + _pageHorizontal, + 0, + _pageHorizontal, + 24, + ), + sliver: SliverGrid( + key: const Key('playlist-detail-grid'), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + crossAxisSpacing: _gridGap, + mainAxisSpacing: 16, + mainAxisExtent: itemExtent, + ), + delegate: SliverChildBuilderDelegate( + (context, index) => HomeFeedCard( + drama: dramas[index], + onTap: () => playDramaFromCatalog(context, dramas[index]), + ), + childCount: dramas.length, + ), + ), + ), + ], + ); + } + + Widget _heading() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + key: const Key('playlist-detail-title'), + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: AppDimens.recommendSectionTitle, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + Text( + subtitle, + key: const Key('playlist-detail-subtitle'), + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: AppDimens.recommendSectionSubtitle, + ), + ), + ], + ); + } +} + +class _PlaylistCollage extends StatelessWidget { + const _PlaylistCollage({required this.dramas}); + + final List dramas; + + @override + Widget build(BuildContext context) { + final covers = dramas.take(_collageCoverCount).toList(growable: false); + return ClipRRect( + key: const Key('playlist-detail-collage'), + borderRadius: BorderRadius.circular(12), + child: SizedBox( + height: _collageHeight, + child: Stack( + fit: StackFit.expand, + children: [ + const ColoredBox(color: _filmBody), + const CustomPaint(painter: _FilmSprocketPainter()), + Padding( + padding: const EdgeInsets.symmetric( + vertical: _sprocketBand, + horizontal: 8, + ), + child: covers.isEmpty + ? const SizedBox.expand() + : Row( + children: [ + for (var index = 0; index < covers.length; index++) ...[ + if (index > 0) const SizedBox(width: 4), + Expanded(child: _frame(covers[index])), + ], + ], + ), + ), + ], + ), + ), + ); + } + + Widget _frame(Drama drama) { + return ClipRRect( + borderRadius: BorderRadius.circular(2), + child: RemoteImage( + drama.posterUrl, + fit: BoxFit.cover, + showFallbackIcon: true, + fallbackColor: _filmBody, + ), + ); + } +} + +class _FilmSprocketPainter extends CustomPainter { + const _FilmSprocketPainter(); + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint()..color = _filmHole; + const holeWidth = 10.0; + const holeHeight = 7.0; + const gap = 8.0; + const inset = 6.0; + final step = holeWidth + gap; + final count = ((size.width - inset * 2 + gap) / step).floor(); + if (count <= 0) return; + final used = count * step - gap; + var x = (size.width - used) / 2; + for (var index = 0; index < count; index++) { + _hole(canvas, paint, x, 7, holeWidth, holeHeight); + _hole( + canvas, + paint, + x, + size.height - 7 - holeHeight, + holeWidth, + holeHeight, + ); + x += step; + } + } + + void _hole( + Canvas canvas, + Paint paint, + double x, + double y, + double width, + double height, + ) { + canvas.drawRRect( + RRect.fromRectAndRadius( + Rect.fromLTWH(x, y, width, height), + const Radius.circular(1.5), + ), + paint, + ); + } + + @override + bool shouldRepaint(covariant _FilmSprocketPainter oldDelegate) => false; +} diff --git a/lib/pages/recommend/widgets/recommend_sections_view.dart b/lib/pages/recommend/widgets/recommend_sections_view.dart index 139c48d..1133ce7 100644 --- a/lib/pages/recommend/widgets/recommend_sections_view.dart +++ b/lib/pages/recommend/widgets/recommend_sections_view.dart @@ -14,6 +14,7 @@ import '../../home/widgets/home_drama_classification.dart'; import 'recommend_section_card.dart'; const List _costumeNames = ['古装', 'costume', 'kostum']; +const int _sectionPreviewCount = 10; enum RecommendSectionKind { hits, newReleases, costume } @@ -35,17 +36,14 @@ class RecommendSectionsView extends StatelessWidget { Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; final source = feedController.dramas; - final hits = [...source] - ..sort((a, b) => b.viewCount.compareTo(a.viewCount)); - final newest = [...source] - ..sort((a, b) { - final left = publishedAtForDrama(a); - final right = publishedAtForDrama(b); - if (left == null && right == null) return b.id.compareTo(a.id); - if (left == null) return 1; - if (right == null) return -1; - return right.compareTo(left); - }); + final hits = recommendSectionDramas( + kind: RecommendSectionKind.hits, + source: source, + ); + final newest = recommendSectionDramas( + kind: RecommendSectionKind.newReleases, + source: source, + ); return AppPullToRefresh( key: const Key('recommend-sections-refresh'), onRefresh: () async { @@ -62,7 +60,7 @@ class RecommendSectionsView extends StatelessWidget { key: 'hits', title: l10n.weeklyHitsTitle, subtitle: l10n.weeklyHitsSubtitle, - dramas: hits.take(10).toList(), + dramas: hits.take(_sectionPreviewCount).toList(), onMore: () => onMore(RecommendSectionKind.hits), ), const SizedBox(height: AppDimens.spaceXl), @@ -72,7 +70,7 @@ class RecommendSectionsView extends StatelessWidget { key: 'new', title: l10n.weeklyNewTitle, subtitle: l10n.weeklyNewSubtitle, - dramas: newest.take(10).toList(), + dramas: newest.take(_sectionPreviewCount).toList(), onMore: () => onMore(RecommendSectionKind.newReleases), ), const SizedBox(height: AppDimens.spaceXl), @@ -82,7 +80,11 @@ class RecommendSectionsView extends StatelessWidget { key: 'costume', title: l10n.costumeAwardsTitle, subtitle: l10n.costumeAwardsSubtitle, - dramas: _costumeDramas(source), + dramas: recommendSectionDramas( + kind: RecommendSectionKind.costume, + source: source, + categories: categories, + ).take(_sectionPreviewCount).toList(), onMore: () => onMore(RecommendSectionKind.costume), ), ], @@ -90,33 +92,6 @@ class RecommendSectionsView extends StatelessWidget { ); } - List _costumeDramas(List source) { - final category = _costumeCategory(); - if (category != null) { - final fromCategory = categories.dramasOf(category.id); - if (fromCategory.isNotEmpty) return fromCategory.take(10).toList(); - } - return source - .where( - (drama) => - _containsCostume(drama.category) || _containsCostume(drama.genre), - ) - .take(10) - .toList(); - } - - ContentCategory? _costumeCategory() { - for (final category in categories.categories) { - if (_containsCostume(category.name)) return category; - } - return null; - } - - bool _containsCostume(String value) { - final normalized = value.toLowerCase(); - return _costumeNames.any(normalized.contains); - } - Widget _section( BuildContext context, AppLocalizations l10n, { @@ -208,3 +183,54 @@ class RecommendSectionsView extends StatelessWidget { ); } } + +/// 剧单分区的完整片单,横滑预览和更多页共用同一排序。 +List recommendSectionDramas({ + required RecommendSectionKind kind, + required List source, + CategoryController? categories, +}) { + switch (kind) { + case RecommendSectionKind.hits: + return [...source]..sort((a, b) => b.viewCount.compareTo(a.viewCount)); + case RecommendSectionKind.newReleases: + return [...source]..sort(_newestFirst); + case RecommendSectionKind.costume: + return _costumeDramas(source, categories); + } +} + +int _newestFirst(Drama leftDrama, Drama rightDrama) { + final left = publishedAtForDrama(leftDrama); + final right = publishedAtForDrama(rightDrama); + if (left == null && right == null) return rightDrama.id.compareTo(leftDrama.id); + if (left == null) return 1; + if (right == null) return -1; + return right.compareTo(left); +} + +List _costumeDramas(List source, CategoryController? categories) { + final category = _costumeCategory(categories); + if (category != null) { + final fromCategory = categories!.dramasOf(category.id); + if (fromCategory.isNotEmpty) return fromCategory; + } + return source.where(_isCostumeDrama).toList(growable: false); +} + +ContentCategory? _costumeCategory(CategoryController? categories) { + if (categories == null) return null; + for (final category in categories.categories) { + if (_containsCostume(category.name)) return category; + } + return null; +} + +bool _isCostumeDrama(Drama drama) { + return _containsCostume(drama.category) || _containsCostume(drama.genre); +} + +bool _containsCostume(String value) { + final normalized = value.toLowerCase(); + return _costumeNames.any(normalized.contains); +} diff --git a/test/recommend/recommend_sections_test.dart b/test/recommend/recommend_sections_test.dart index 54b4997..3a7f8b6 100644 --- a/test/recommend/recommend_sections_test.dart +++ b/test/recommend/recommend_sections_test.dart @@ -250,6 +250,45 @@ void main() { expect(tester.getTopLeft(tagsFinder).dx, tester.getTopLeft(coverFinder).dx); }); + testWidgets('should_open_playlist_detail_with_collage_and_two_columns', ( + tester, + ) async { + await _pumpRecommend(tester); + await tester.tap(find.byKey(const Key('recommend-tab-playlist'))); + await tester.pumpAndSettle(); + await tester.ensureVisible(find.byKey(const Key('recommend-more-hits'))); + await tester.tap(find.byKey(const Key('recommend-more-hits'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('playlist-detail-page')), findsOneWidget); + expect(find.byKey(const Key('playlist-detail-collage')), findsOneWidget); + expect( + tester.widget(find.byKey(const Key('playlist-detail-title'))).data, + "This Week's Hits", + ); + expect( + tester + .widget(find.byKey(const Key('playlist-detail-subtitle'))) + .data, + 'Binge the hottest stories of the week!', + ); + final grid = tester.widget( + find.byKey(const Key('playlist-detail-grid')), + ); + final delegate = + grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount; + expect(delegate.crossAxisCount, 2); + final first = tester.getRect( + find.byKey(const Key('home-feed-card-the-signal-demo')), + ); + final second = tester.getRect( + find.byKey(const Key('home-feed-card-city-lights-demo')), + ); + expect(second.top, closeTo(first.top, 1)); + expect(second.left, greaterThan(first.right)); + while (tester.takeException() != null) {} + }); + testWidgets('should_switch_inner_tabs_without_losing_grid_filter_state', ( tester, ) async { @@ -521,7 +560,7 @@ void main() { ) async { await _pumpRecommend(tester); - await tester.tap(find.byKey(const Key('home-feed-title-open'))); + await tester.tap(find.byKey(const Key('home-feed-series-name'))); await tester.pumpAndSettle(); expect( tester.widget(find.byType(PlayerPage).last).homeMode, @@ -540,6 +579,34 @@ void main() { ); }); + testWidgets('should_pull_refresh_recommend_feed_from_the_first_video', ( + tester, + ) async { + await _pumpRecommend(tester); + + final gesture = await tester.startGesture( + tester.getCenter(find.byKey(const Key('recommend-feed-player'))), + ); + await gesture.moveBy(const Offset(0, 80)); + await tester.pump(); + expect(find.byKey(const Key('pull-refresh-indicator')), findsOneWidget); + final feedScroll = tester.state( + find + .descendant( + of: find.byKey(const Key('recommend-feed-player')), + matching: find.byType(Scrollable), + ) + .first, + ); + expect(feedScroll.position.pixels, 0); + + await gesture.up(); + await tester.pump(); + expect(feedScroll.position.pixels, 0); + await tester.pump(const Duration(milliseconds: 400)); + while (tester.takeException() != null) {} + }); + testWidgets('should_open_player_and_search_from_recommend_page', ( tester, ) async {