Files
ReelShort/lib/pages/recommend/recommend_discover_page.dart

494 lines
16 KiB
Dart

// Author: fruit.com.cn
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../core/data/models/drama.dart';
import '../../core/data/models/home_feed.dart';
import '../../core/data/remote/catalog_api.dart';
import '../../core/data/services/category_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 '../player/open_player.dart';
import '../home/widgets/home_ranking_dramas.dart';
import 'widgets/home_hero_carousel.dart';
import 'widgets/home_recommend_content.dart';
import 'widgets/recommend_channel_nav.dart';
/// 底部第二个 tab「推荐」(礼花)。
///
/// 频道全部来自分类接口;内容区是一个横向 [PageView],左右滑动即可切换频道,
/// 顶部频道导航随之高亮,并把选中频道滚动到可见区域。
/// 第一个频道(接口的「精选」)在网格顶部额外挂一张 banners 轮播图,
/// 轮播数据来自 `GET /app/home/feed`。
class RecommendDiscoverPage extends StatefulWidget {
const RecommendDiscoverPage({
super.key,
required this.active,
required this.onSearch,
this.onOpenWallet,
});
final bool active;
final VoidCallback onSearch;
/// 顶部金色礼物盒(钱包入口)。
final VoidCallback? onOpenWallet;
@override
State<RecommendDiscoverPage> createState() => _RecommendDiscoverPageState();
}
class _RecommendDiscoverPageState extends State<RecommendDiscoverPage>
with AutomaticKeepAliveClientMixin<RecommendDiscoverPage> {
/// 当前频道 id;频道全部来自分类接口,首项即「精选」。
String? _channelId;
final Set<String> _requestedCategories = {};
final PageController _pageController = PageController();
/// `GET /app/home/feed` 返回的轮播图,只挂在第一个「精选」频道。
List<FeedBanner> _banners = const [];
bool _bannersRequested = false;
@override
bool get wantKeepAlive => true;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _loadBanners());
}
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
/// 点击顶部频道:更新高亮并让 PageView 滑到对应页。
void _selectChannel(String id, int index) {
if (id == _channelId && _pageController.hasClients) {
// 已在该页时仍允许回到顶部对齐。
return;
}
setState(() => _channelId = id);
_ensureCategory(id);
if (_pageController.hasClients) {
unawaited(
_pageController.animateToPage(
index,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
),
);
}
}
/// 左右滑动翻页:更新高亮(不反向驱动 PageView)。
void _onPageChanged(int index, List<String> orderedIds) {
if (index < 0 || index >= orderedIds.length) return;
final id = orderedIds[index];
if (id != _channelId) setState(() => _channelId = id);
_ensureCategory(id);
}
void _ensureCategory(String id) {
if (_requestedCategories.contains(id)) return;
_requestedCategories.add(id);
final categories = context.read<CategoryController>();
final category = categories.categories
.where((item) => item.id == id)
.firstOrNull;
if (category?.isRanking ?? false) {
final slot = categories.rankingSlotOf(id);
if (categories.rankingLoader == null ||
slot.ranking != null ||
slot.loading) {
return;
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) unawaited(categories.openRanking(id));
});
return;
}
if (categories.dramaPageLoader == null) return;
final slot = categories.slotOf(id);
if (slot.dramas.isNotEmpty || slot.loading) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) unawaited(categories.openCategory(id));
});
}
/// 下拉刷新:重拉该分类第一页;精选频道同时刷新轮播图。
Future<void> _refreshCategory(String id, bool isFeatured) async {
final categories = context.read<CategoryController>();
final tasks = <Future<void>>[categories.openCategory(id)];
if (isFeatured) tasks.add(_loadBanners(force: true));
await Future.wait(tasks);
}
/// 轮播图只依赖 home/feed 的 banners;接口缺失(如测试注入)时静默跳过,
/// 不影响下方分类网格。
Future<void> _loadBanners({bool force = false}) async {
if (_bannersRequested && !force) return;
_bannersRequested = true;
CatalogApi? api;
try {
api = context.read<CatalogApi>();
} on ProviderNotFoundException {
api = null;
}
if (api == null) return;
try {
final feed = await api.homeFeed();
if (mounted && feed.banners.isNotEmpty) {
setState(() => _banners = feed.banners);
}
} catch (_) {
// 轮播拉取失败时保留纯网格。
}
}
Future<void> _openDrama(Drama drama) => playDramaFromCatalog(context, drama);
@override
Widget build(BuildContext context) {
super.build(context);
final categories = context.watch<CategoryController>();
final mediaTop = MediaQuery.of(context).padding.top;
final categoryList = categories.categories;
final orderedIds = categoryList.map((c) => c.id).toList();
// 频道来自接口分类;默认选中第一个分类(即「精选」)。
_channelId ??= categoryList.isEmpty ? null : categoryList.first.id;
final selectedIndex = _channelId == null
? 0
: orderedIds.indexOf(_channelId!).clamp(0, orderedIds.length - 1);
return ColoredBox(
key: const Key('recommend-page'),
color: AppColors.appBackground,
child: Stack(
children: [
Positioned.fill(
child: categoryList.isEmpty
? const Center(
child: CircularProgressIndicator(color: AppColors.brandRed),
)
: PageView.builder(
key: const Key('recommend-channel-pager'),
controller: _pageController,
itemCount: categoryList.length,
onPageChanged: (i) => _onPageChanged(i, orderedIds),
itemBuilder: (context, index) {
final category = categoryList[index];
if (category.isRanking) {
return HomeRankingDramas(
key: PageStorageKey<String>(
'recommend-ranking-${category.id}',
),
categoryId: category.id,
topPadding: mediaTop + RecommendChannelNav.height,
onOpenDrama: _openDrama,
);
}
return _ChannelFeed(
key: PageStorageKey<String>(
'recommend-channel-${category.id}',
),
categoryId: category.id,
isFeatured: index == 0,
banners: _banners,
carouselActive: widget.active && index == selectedIndex,
mediaTop: mediaTop,
onRequest: _ensureCategory,
onRefresh: () =>
_refreshCategory(category.id, index == 0),
onOpenDrama: _openDrama,
);
},
),
),
Positioned(
top: 0,
left: 0,
right: 0,
child: IgnorePointer(
child: SizedBox(
height: mediaTop + RecommendChannelNav.height,
child: const DecoratedBox(
key: Key('recommend-top-gradient'),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [AppColors.mediaBlack, AppColors.transparent],
),
),
),
),
),
),
Positioned(
top: 0,
left: 0,
right: 0,
child: SafeArea(
bottom: false,
child: RecommendChannelNav(
categories: categoryList,
selectedId: _channelId,
onSelected: (id) {
final index = orderedIds.indexOf(id);
if (index >= 0) _selectChannel(id, index);
},
onSearch: widget.onSearch,
onGift: widget.onOpenWallet,
),
),
),
],
),
);
}
}
/// 单个分类频道的内容:可选顶部轮播 + 该分类短剧三列网格。
class _ChannelFeed extends StatefulWidget {
const _ChannelFeed({
super.key,
required this.categoryId,
required this.isFeatured,
required this.banners,
required this.carouselActive,
required this.mediaTop,
required this.onRequest,
required this.onRefresh,
required this.onOpenDrama,
});
final String categoryId;
final bool isFeatured;
final List<FeedBanner> banners;
final bool carouselActive;
final double mediaTop;
final ValueChanged<String> onRequest;
final Future<void> Function() onRefresh;
final ValueChanged<Drama> onOpenDrama;
@override
State<_ChannelFeed> createState() => _ChannelFeedState();
}
class _ChannelFeedState extends State<_ChannelFeed>
with AutomaticKeepAliveClientMixin {
@override
bool get wantKeepAlive => true;
@override
void initState() {
super.initState();
widget.onRequest(widget.categoryId);
}
Drama? _dramaForBanner(FeedBanner banner, List<Drama> pool) {
final id = banner.dramaId;
if (id == null) return null;
for (final drama in pool) {
if (drama.id == id) return drama;
}
return null;
}
@override
Widget build(BuildContext context) {
super.build(context);
final categories = context.watch<CategoryController>();
final slot = categories.slotOf(widget.categoryId);
final dramas = slot.dramas;
final showCarousel = widget.isFeatured && widget.banners.isNotEmpty;
final bottomInset = MediaQuery.of(context).padding.bottom;
// 精选频道:按接口下发的标签(tag)把「上新」筛出来横滑,其余短剧一行两个。
List<Drama> newArrivals = const [];
List<Drama> rest = dramas;
if (widget.isFeatured && dramas.isNotEmpty) {
newArrivals = dramas.where(isNewArrivalTag).toList(growable: false);
if (newArrivals.isNotEmpty) {
final newIds = newArrivals.map((drama) => drama.id).toSet();
rest = dramas
.where((drama) => !newIds.contains(drama.id))
.toList(growable: false);
}
}
final sliverList = <Widget>[];
if (showCarousel) {
sliverList.add(
SliverToBoxAdapter(
child: HomeHeroCarousel(
banners: widget.banners,
active: widget.carouselActive,
dramaFor: (banner) => _dramaForBanner(banner, dramas),
onWatch: widget.onOpenDrama,
),
),
);
}
if (dramas.isEmpty) {
sliverList.add(
SliverFillRemaining(
hasScrollBody: false,
child: Center(child: _placeholder(slot.loading)),
),
);
} else if (widget.isFeatured) {
// 无轮播图时内容需避开顶部悬浮导航。
final contentTop = showCarousel
? 24.0
: widget.mediaTop + RecommendChannelNav.height + AppDimens.spaceSm;
if (newArrivals.isNotEmpty) {
sliverList.add(SliverToBoxAdapter(child: SizedBox(height: contentTop)));
sliverList.add(
SliverToBoxAdapter(
child: HomeNewArrivalRow(
dramas: newArrivals,
onOpen: widget.onOpenDrama,
),
),
);
sliverList.add(const SliverToBoxAdapter(child: SizedBox(height: 28)));
}
sliverList.add(
_gridSliver(
rest,
topPadding: newArrivals.isNotEmpty ? 0 : contentTop,
crossAxisCount: 2,
),
);
} else {
sliverList.add(
_gridSliver(
dramas,
topPadding: showCarousel
? 20
: widget.mediaTop +
RecommendChannelNav.height +
AppDimens.spaceSm,
crossAxisCount: 3,
),
);
}
sliverList.add(
SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.only(bottom: bottomInset),
child: PagedListFooter(
key: const Key('recommend-category-footer'),
loading: slot.loadingMore,
hasMore: slot.hasMore,
hasItems: dramas.isNotEmpty,
errorMessage: null,
onRetry: () => categories.loadMore(widget.categoryId),
),
),
),
);
return NotificationListener<ScrollNotification>(
onNotification: (notification) {
if (notification.depth != 0) return false;
if (notification.metrics.extentAfter > 300) return false;
if (notification is! ScrollUpdateNotification &&
notification is! ScrollEndNotification &&
notification is! OverscrollNotification) {
return false;
}
unawaited(categories.loadMore(widget.categoryId));
return false;
},
child: AppPullToRefresh(
edgeOffset:
widget.mediaTop +
RecommendChannelNav.height -
AppPullToRefresh.indicatorRevealOffset,
onRefresh: widget.onRefresh,
child: CustomScrollView(
key: PageStorageKey<String>('recommend-feed-${widget.categoryId}'),
physics: AppPullToRefresh.physics,
slivers: sliverList,
),
),
);
}
Widget _placeholder(bool loading) {
if (loading) {
return const CircularProgressIndicator(color: AppColors.brandRed);
}
final l10n = AppLocalizations.of(context)!;
return EmptyState(
key: const Key('recommend-category-empty'),
icon: Icons.movie_outlined,
title: l10n.noStories,
compact: true,
stretchContent: true,
);
}
Widget _gridSliver(
List<Drama> dramas, {
required double topPadding,
required int crossAxisCount,
}) {
const pageSide = 14.0;
const cardGap = 9.0;
const rowGap = 16.0;
// 两列时卡更大,卡间距略放宽。
final spacing = crossAxisCount == 2 ? 12.0 : cardGap;
return SliverPadding(
padding: EdgeInsets.fromLTRB(pageSide, topPadding, pageSide, 0),
sliver: SliverLayoutBuilder(
builder: (context, constraints) {
final itemWidth =
(constraints.crossAxisExtent - spacing * (crossAxisCount - 1)) /
crossAxisCount;
final itemExtent = itemWidth * 157 / 110 + homePosterCardBodyExtent;
return SliverGrid(
key: Key('recommend-category-grid-${widget.categoryId}'),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: crossAxisCount,
crossAxisSpacing: spacing,
mainAxisSpacing: rowGap,
mainAxisExtent: itemExtent,
),
delegate: SliverChildBuilderDelegate((context, index) {
final drama = dramas[index];
return HomePosterCard(
key: Key('recommend-category-card-${drama.id}'),
drama: drama,
onTap: () => widget.onOpenDrama(drama),
);
}, childCount: dramas.length),
);
},
),
);
}
}
/// 「上新」标签语义码,对应后台 content_tag.code。
const String newArrivalTagCode = 'NEW_ARRIVAL';
/// 判定短剧是否属于「上新」板块。
///
/// 只看语义码:码由后台标签管理配置,文案怎么改、切什么语言都不影响。
bool isNewArrivalTag(Drama drama) =>
drama.tagCode.trim().toUpperCase() == newArrivalTagCode;