feat(recommend): 剧单更多进入两列详情页

This commit is contained in:
Zhou Weidong committed 2026-10-23 03:18:24 +08:00
1 parent f4cf7bc2dd
commit 9b1afbdb36
4 files changed
+408 -43

No files matched your search

@@ -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<Drama> 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<Drama> 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;
}
@@ -14,6 +14,7 @@ import '../../home/widgets/home_drama_classification.dart';
import 'recommend_section_card.dart';
const List<String> _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<Drama> _costumeDramas(List<Drama> 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<Drama> recommendSectionDramas({
required RecommendSectionKind kind,
required List<Drama> 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<Drama> _costumeDramas(List<Drama> 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);
}