feat(recommend): 新增推荐频道页并恢复首页沉浸式信息流

- tab1 推荐页改为接口分类频道:频道导航可横滑,选中频道自动滚动到可视区,内容区左右滑动切换分类
- 精选频道:顶部固定高度 banner 轮播图 + 接口 tag 筛选的上新短剧横滑 + 其余短剧双列网格;其他分类三列网格
- 分类内容支持懒加载、下拉刷新与上拉分页加载
- 卡片角标文案与背景色取自接口 tag/tagColor,短剧标题统一白色
- 恢复 tab0 为沉浸式全屏视频首页及其三 inner-tab 顶导(搜索/礼物入口)
- l10n 新增原创出品、立即观看、上新、编辑精选、全N集等多语言文案
This commit is contained in:
Vicky authored and trae-solo-agent committed 2026-10-27 19:57:42 +08:00
1 parent a3f3481f4f
commit c027acc91f
22 files changed
+2018 -318

No files matched your search

@@ -0,0 +1,252 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import '../../../core/data/models/drama.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/widgets/remote_image.dart';
import '../../../l10n/app_localizations.dart';
/// 设计稿(首页 1)推荐页内容区规格:
/// 海报卡 110×157、页边距 14、卡距 9、圆角 12。
const double _pageSide = 14;
const double _cardWidth = 110;
const double _cardCoverHeight = 157;
const double _cardGap = 9;
const double _cardRadius = 12;
/// 上新横滑卡板块。无板块标题,卡片左上角带「上新」角标。
/// 内层横向 ListView 会自行识别横向手势,不与外层纵向滚动冲突。
class HomeNewArrivalRow extends StatelessWidget {
const HomeNewArrivalRow({
super.key,
required this.dramas,
required this.onOpen,
});
final List<Drama> dramas;
final ValueChanged<Drama> onOpen;
@override
Widget build(BuildContext context) {
if (dramas.isEmpty) return const SizedBox.shrink();
return SizedBox(
key: const Key('home-new-arrival-row'),
height:
_cardCoverHeight + 8 + 20 + 2 + 16, // 封面 + 剧名行 + 集数行 + 余量
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: _pageSide),
itemCount: dramas.length,
separatorBuilder: (_, _) => const SizedBox(width: _cardGap),
itemBuilder: (context, index) => SizedBox(
width: _cardWidth,
child: HomePosterCard(
key: Key('home-new-arrival-${dramas[index].id}'),
drama: dramas[index],
badge: AppLocalizations.of(context)!.newArrivalBadge,
onTap: () => onOpen(dramas[index]),
),
),
),
);
}
}
/// 110×157 海报 + 卡下剧名/集数。
///
/// 封面左上角标签优先用接口返回的短剧标签(`drama.tag` + `drama.tagColor`);
/// 接口没有标签时,若传了 [badge](如「上新」)则退回显示该角标。
class HomePosterCard extends StatelessWidget {
const HomePosterCard({
super.key,
required this.drama,
required this.onTap,
this.badge,
});
final Drama drama;
final VoidCallback onTap;
final String? badge;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final lang = Localizations.localeOf(context).languageCode;
final tag = drama.localizedTag(lang);
final tagLabel = tag.isNotEmpty ? tag : (badge ?? '');
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(_cardRadius),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(_cardRadius),
child: AspectRatio(
aspectRatio: _cardWidth / _cardCoverHeight,
child: Stack(
fit: StackFit.expand,
children: [
RemoteImage(
drama.posterUrl,
fit: BoxFit.cover,
showFallbackIcon: true,
fallbackColor: AppColors.contentSurface,
),
if (tagLabel.isNotEmpty)
Positioned(
left: 8,
top: 8,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 3,
),
decoration: BoxDecoration(
color: tag.isNotEmpty
? drama.tagRibbonColor(AppColors.brandRed)
: AppColors.brandRed,
borderRadius: BorderRadius.circular(6),
),
child: Text(
tagLabel,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: 11,
fontWeight: FontWeight.w600,
height: 1.1,
),
),
),
),
],
),
),
),
const SizedBox(height: 8),
Text(
drama.localizedTitle(lang),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: 14,
fontWeight: FontWeight.w500,
height: 1.2,
),
),
const SizedBox(height: 2),
Text(
l10n.totalEpisodesShort(drama.episodeCount),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.textTertiary,
fontSize: 12,
height: 1.2,
),
),
],
),
);
}
}
/// 编辑精选横幅:满宽背景图 + 左上小标签、大标题、副标题。
class HomeEditorPickBanner extends StatelessWidget {
const HomeEditorPickBanner({
super.key,
required this.drama,
required this.onOpen,
});
final Drama drama;
final ValueChanged<Drama> onOpen;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final lang = Localizations.localeOf(context).languageCode;
return Padding(
key: const Key('home-editor-pick'),
padding: const EdgeInsets.symmetric(horizontal: _pageSide),
child: SizedBox(
height: 72,
child: ClipRRect(
borderRadius: BorderRadius.circular(_cardRadius),
child: Stack(
fit: StackFit.expand,
children: [
RemoteImage(
drama.posterUrl,
fit: BoxFit.cover,
showFallbackIcon: true,
fallbackColor: AppColors.contentSurface,
),
const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [Color(0xE6000000), Color(0x4D000000), Color(0x1A000000)],
),
),
),
Positioned.fill(
child: Material(
color: AppColors.transparent,
child: InkWell(onTap: () => onOpen(drama)),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 14),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.editorPick,
maxLines: 1,
style: TextStyle(
color: AppColors.textPrimary.withValues(alpha: 0.85),
fontSize: 11,
fontWeight: FontWeight.w500,
letterSpacing: 2,
),
),
const SizedBox(height: 4),
Text(
drama.localizedTitle(lang),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: 18,
fontWeight: FontWeight.w600,
height: 1.15,
),
),
const SizedBox(height: 2),
Text(
drama.localizedSynopsis(lang),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: AppColors.textPrimary.withValues(alpha: 0.8),
fontSize: 12,
),
),
],
),
),
],
),
),
),
);
}
}