feat(recommend): 新增分区流全部短剧与剧单

This commit is contained in:
Zhou Weidong committed 2026-10-22 17:23:22 +08:00
1 parent 0ef2438100
commit f1b5418954
22 files changed
+1426 -722

No files matched your search

@@ -0,0 +1,292 @@
// Author: fruit.com.cn
import 'dart:async';
import 'package:flutter/material.dart';
import '../../../core/data/models/drama.dart';
import '../../../core/data/services/home_feed_controller.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/widgets/app_pull_to_refresh.dart';
import '../../../core/widgets/empty_state.dart';
import '../../../core/widgets/paged_list_footer.dart';
import '../../../l10n/app_localizations.dart';
import '../../home/widgets/home_drama_classification.dart';
import '../../home/widgets/home_feed_card.dart';
const double _loadMoreThreshold = 240;
const List<String> _modernTerms = ['现代', 'modern', 'moderno'];
const List<String> _reversalTerms = ['逆袭', 'comeback', 'balas'];
class AllDramasFilterGrid extends StatefulWidget {
const AllDramasFilterGrid({
super.key,
required this.feedController,
required this.onOpenDrama,
required this.onOpenFilter,
});
final HomeFeedController feedController;
final ValueChanged<Drama> onOpenDrama;
final VoidCallback onOpenFilter;
@override
State<AllDramasFilterGrid> createState() => _AllDramasFilterGridState();
}
class _AllDramasFilterGridState extends State<AllDramasFilterGrid> {
String? _genre;
bool _newest = false;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final dramas = _filtered();
return LayoutBuilder(
builder: (context, constraints) {
final itemWidth =
(constraints.maxWidth -
AppDimens.pageHorizontal * 2 -
AppDimens.homeGridGap * 2) /
3;
final itemExtent = itemWidth * 6 / 5 + AppDimens.homeCardBodyExtent;
return NotificationListener<ScrollNotification>(
onNotification: _onScroll,
child: AppPullToRefresh(
key: const Key('all-dramas-refresh'),
onRefresh: () async {
await widget.feedController.refreshCurrent();
},
child: CustomScrollView(
key: const Key('all-dramas-scroll'),
physics: AppPullToRefresh.physics,
slivers: [
SliverToBoxAdapter(child: _filters(l10n)),
if (dramas.isEmpty)
SliverFillRemaining(
hasScrollBody: false,
child: EmptyState(
key: const Key('all-dramas-empty'),
icon: Icons.movie_filter_outlined,
title: l10n.homeGridFilterEmpty,
actionLabel: l10n.homeGridClearFilter,
onAction: () => setState(() => _genre = null),
),
)
else
SliverPadding(
padding: const EdgeInsets.symmetric(
horizontal: AppDimens.pageHorizontal,
),
sliver: SliverGrid(
key: const Key('all-dramas-grid'),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
crossAxisSpacing: AppDimens.homeGridGap,
mainAxisSpacing: AppDimens.homeGridGap,
mainAxisExtent: itemExtent,
),
delegate: SliverChildBuilderDelegate(
(context, index) => HomeFeedCard(
drama: dramas[index],
onTap: () => widget.onOpenDrama(dramas[index]),
),
childCount: dramas.length,
),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(bottom: AppDimens.spaceXl),
child: PagedListFooter(
loading: widget.feedController.loadingMore,
hasMore: widget.feedController.hasMore,
hasItems: dramas.isNotEmpty,
errorMessage: widget.feedController.lastError == null
? null
: l10n.catalogError,
onRetry: widget.feedController.loadMore,
),
),
),
],
),
),
);
},
);
}
Widget _filters(AppLocalizations l10n) {
final genres = _availableGenres(l10n);
return Padding(
padding: const EdgeInsets.fromLTRB(
AppDimens.pageHorizontal,
AppDimens.spaceSm,
AppDimens.pageHorizontal,
AppDimens.spaceMd,
),
child: Column(
key: const Key('all-dramas-filters'),
children: [
_chipRow(0, [l10n.all]),
_chipRow(1, [l10n.all]),
_chipRow(2, [
(id: null, label: l10n.all),
for (final genre in genres) (id: genre.id, label: genre.label),
]),
Row(
key: const Key('all-filter-row-3'),
children: [
Expanded(
child: Row(
children: [
_chip(
key: const Key('all-sort-hot'),
label: l10n.hot,
selected: !_newest,
onTap: () => setState(() => _newest = false),
),
const SizedBox(width: AppDimens.spaceSm),
_chip(
key: const Key('all-sort-new'),
label: l10n.newest,
selected: _newest,
onTap: () => setState(() => _newest = true),
),
],
),
),
IconButton(
key: const Key('all-dramas-filter-page'),
tooltip: l10n.filter,
onPressed: widget.onOpenFilter,
color: AppColors.textPrimary,
iconSize: AppDimens.tapTarget / 2,
icon: const Icon(Icons.filter_list_rounded),
),
],
),
],
),
);
}
Widget _chipRow(int row, List<Object> options) {
return Padding(
key: Key('all-filter-row-$row'),
padding: const EdgeInsets.only(bottom: AppDimens.spaceSm),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (final option in options) ...[
if (option is String)
_chip(label: option, selected: true, onTap: () {}),
if (option is ({String? id, String label}))
_chip(
label: option.label,
selected: _genre == option.id,
onTap: () => setState(() => _genre = option.id),
),
const SizedBox(width: AppDimens.spaceSm),
],
],
),
),
);
}
Widget _chip({
Key? key,
required String label,
required bool selected,
required VoidCallback onTap,
}) {
return InkWell(
key: key,
onTap: onTap,
borderRadius: BorderRadius.circular(AppDimens.pillRadius),
child: Container(
height: 32,
padding: const EdgeInsets.symmetric(horizontal: AppDimens.spaceMd),
alignment: Alignment.center,
decoration: BoxDecoration(
color: selected ? AppColors.contentChip : AppColors.contentSurface,
borderRadius: BorderRadius.circular(AppDimens.pillRadius),
),
child: Text(
label,
style: TextStyle(
color: selected ? AppColors.textPrimary : AppColors.textSecondary,
fontSize: AppDimens.caption,
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
),
),
),
);
}
List<({String id, String label})> _availableGenres(AppLocalizations l10n) {
final source = widget.feedController.dramas;
final definitions = [
(id: 'reversal', label: l10n.reversalFilter, terms: _reversalTerms),
(id: 'uplifting', label: l10n.homeTagUplifting, terms: <String>[]),
(id: 'modern', label: l10n.modernFilter, terms: _modernTerms),
(id: 'sweet', label: l10n.homeTagSweet, terms: <String>[]),
(id: 'angst', label: l10n.homeTagAngst, terms: <String>[]),
];
return definitions
.where(
(definition) => source.any(
(drama) => _matchesGenre(drama, definition.id, definition.terms),
),
)
.map((definition) => (id: definition.id, label: definition.label))
.toList(growable: false);
}
List<Drama> _filtered() {
final dramas = widget.feedController.dramas
.where((drama) {
return _genre == null || _matchesGenre(drama, _genre!, const []);
})
.toList(growable: false);
if (_newest) {
return [...dramas]..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);
});
}
return [...dramas]..sort((a, b) => b.viewCount.compareTo(a.viewCount));
}
bool _matchesGenre(Drama drama, String id, List<String> terms) {
if (id == 'reversal' ||
id == 'uplifting' ||
id == 'sweet' ||
id == 'angst') {
return matchesHomeTag(drama, id == 'reversal' ? 'uplifting' : id);
}
final text = '${drama.genre} ${drama.category}'.toLowerCase();
return terms.any(text.contains);
}
bool _onScroll(ScrollNotification notification) {
if (notification.depth != 0) return false;
final reachedBottom =
notification.metrics.extentAfter <= _loadMoreThreshold;
if (reachedBottom &&
(notification is ScrollUpdateNotification ||
notification is ScrollEndNotification ||
notification is OverscrollNotification)) {
unawaited(widget.feedController.loadMore());
}
return false;
}
}
@@ -0,0 +1,116 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import '../../../core/data/models/drama.dart';
import '../../../core/formatting/count_format.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/widgets/remote_image.dart';
import '../../../l10n/app_localizations.dart';
import '../../home/widgets/home_drama_classification.dart';
class RecommendSectionCard extends StatelessWidget {
const RecommendSectionCard({
super.key,
required this.sectionKey,
required this.drama,
required this.onTap,
});
final String sectionKey;
final Drama drama;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return SizedBox(
width: 122,
child: InkWell(
key: Key('recommend-card-$sectionKey-${drama.id}'),
borderRadius: BorderRadius.circular(AppDimens.homeCardRadius),
onTap: onTap,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(AppDimens.homeCardRadius),
child: AspectRatio(
aspectRatio: 3 / 4,
child: Stack(
fit: StackFit.expand,
children: [
RemoteImage(
drama.posterUrl,
fit: BoxFit.cover,
showFallbackIcon: true,
fallbackColor: AppColors.contentSurface,
),
if (isHotDrama(drama))
Positioned(
top: AppDimens.spaceXs,
left: AppDimens.spaceXs,
child: Container(
key: Key('recommend-hot-$sectionKey-${drama.id}'),
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: AppColors.badgeHot,
borderRadius: BorderRadius.circular(
AppDimens.chipRadius,
),
),
child: Text(
l10n.homeBadgeHot,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: AppDimens.homeCardBadge,
fontWeight: FontWeight.w700,
),
),
),
),
],
),
),
),
const SizedBox(height: AppDimens.spaceXs),
Row(
children: [
const Icon(
Icons.play_arrow_rounded,
color: AppColors.textSecondary,
size: AppDimens.homeCardMeta,
),
Text(
formatInteractionCount(drama.viewCount),
style: const TextStyle(
color: AppColors.textSecondary,
fontSize: AppDimens.homeCardMeta,
),
),
],
),
const SizedBox(height: 2),
Text(
drama.title,
key: Key('recommend-title-$sectionKey-${drama.id}'),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: AppDimens.homeCardTitle,
fontWeight: FontWeight.w600,
height: 1.25,
),
),
],
),
),
);
}
}
@@ -0,0 +1,209 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import '../../../core/data/models/content_category.dart';
import '../../../core/data/models/drama.dart';
import '../../../core/data/services/category_controller.dart';
import '../../../core/data/services/home_feed_controller.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/widgets/app_pull_to_refresh.dart';
import '../../../l10n/app_localizations.dart';
import '../../home/widgets/home_drama_classification.dart';
import 'recommend_section_card.dart';
const List<String> _costumeNames = ['古装', 'costume', 'kostum'];
enum RecommendSectionKind { hits, newReleases, costume }
class RecommendSectionsView extends StatelessWidget {
const RecommendSectionsView({
super.key,
required this.feedController,
required this.categories,
required this.onOpenDrama,
required this.onMore,
});
final HomeFeedController feedController;
final CategoryController categories;
final ValueChanged<Drama> onOpenDrama;
final ValueChanged<RecommendSectionKind> onMore;
@override
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);
});
return AppPullToRefresh(
key: const Key('recommend-sections-refresh'),
onRefresh: () async {
await Future.wait([feedController.refreshCurrent(), categories.load()]);
},
child: ListView(
key: const Key('recommend-sections-scroll'),
physics: AppPullToRefresh.physics,
padding: const EdgeInsets.only(bottom: AppDimens.spaceXl),
children: [
_section(
context,
l10n,
key: 'hits',
title: l10n.weeklyHitsTitle,
subtitle: l10n.weeklyHitsSubtitle,
dramas: hits.take(10).toList(),
onMore: () => onMore(RecommendSectionKind.hits),
),
const SizedBox(height: AppDimens.spaceXl),
_section(
context,
l10n,
key: 'new',
title: l10n.weeklyNewTitle,
subtitle: l10n.weeklyNewSubtitle,
dramas: newest.take(10).toList(),
onMore: () => onMore(RecommendSectionKind.newReleases),
),
const SizedBox(height: AppDimens.spaceXl),
_section(
context,
l10n,
key: 'costume',
title: l10n.costumeAwardsTitle,
subtitle: l10n.costumeAwardsSubtitle,
dramas: _costumeDramas(source),
onMore: () => onMore(RecommendSectionKind.costume),
),
],
),
);
}
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, {
required String key,
required String title,
required String subtitle,
required List<Drama> dramas,
required VoidCallback onMore,
}) {
return Column(
key: Key('recommend-section-$key'),
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(
horizontal: AppDimens.pageHorizontal,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: AppDimens.sectionTitle,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 2),
Text(
subtitle,
style: const TextStyle(
color: AppColors.textSecondary,
fontSize: AppDimens.cardViews,
),
),
],
),
),
TextButton(
key: Key('recommend-more-$key'),
onPressed: onMore,
style: TextButton.styleFrom(
foregroundColor: AppColors.textSecondary,
padding: const EdgeInsets.symmetric(horizontal: 4),
minimumSize: const Size(0, 30),
),
child: Row(
children: [
Text(
l10n.more,
style: const TextStyle(
fontSize: AppDimens.caption,
fontWeight: FontWeight.w500,
),
),
const Icon(Icons.chevron_right_rounded, size: 16),
],
),
),
],
),
),
const SizedBox(height: AppDimens.spaceMd),
SizedBox(
height: 218,
child: ListView.separated(
key: Key('recommend-row-$key'),
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(
horizontal: AppDimens.pageHorizontal,
),
itemCount: dramas.length,
separatorBuilder: (_, _) =>
const SizedBox(width: AppDimens.spaceMd),
itemBuilder: (context, index) => RecommendSectionCard(
sectionKey: key,
drama: dramas[index],
onTap: () => onOpenDrama(dramas[index]),
),
),
),
],
);
}
}