466 lines
15 KiB
Dart
466 lines
15 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/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 '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>();
|
|
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];
|
|
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: Padding(
|
|
padding: EdgeInsets.only(
|
|
top: showCarousel
|
|
? 0
|
|
: widget.mediaTop + RecommendChannelNav.height,
|
|
),
|
|
child: Center(child: _placeholder(slot.loading)),
|
|
),
|
|
),
|
|
);
|
|
} else if (widget.isFeatured) {
|
|
// 无轮播图时内容需避开顶部悬浮导航。
|
|
final contentTop = showCarousel
|
|
? 24.0
|
|
: widget.mediaTop + RecommendChannelNav.height + 16;
|
|
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 + 16,
|
|
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(
|
|
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,
|
|
);
|
|
}
|
|
|
|
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;
|