feat(recommend): 新增推荐频道页并恢复首页沉浸式信息流
- tab1 推荐页改为接口分类频道:频道导航可横滑,选中频道自动滚动到可视区,内容区左右滑动切换分类 - 精选频道:顶部固定高度 banner 轮播图 + 接口 tag 筛选的上新短剧横滑 + 其余短剧双列网格;其他分类三列网格 - 分类内容支持懒加载、下拉刷新与上拉分页加载 - 卡片角标文案与背景色取自接口 tag/tagColor,短剧标题统一白色 - 恢复 tab0 为沉浸式全屏视频首页及其三 inner-tab 顶导(搜索/礼物入口) - l10n 新增原创出品、立即观看、上新、编辑精选、全N集等多语言文案
This commit is contained in:
1 parent
a3f3481f4f
commit
c027acc91f
22 files changed
+2018
-318
No files matched your search
@@ -0,0 +1,222 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/data/models/content_category.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
|
||||
const double _navHeight = 56;
|
||||
const double _tabGap = 20;
|
||||
const double _tabFont = 17;
|
||||
const double _searchHitSize = 40;
|
||||
const double _searchIconSize = 26;
|
||||
const double _giftIconSize = 30;
|
||||
const double _underlineWidth = 26;
|
||||
const double _underlineHeight = 3;
|
||||
|
||||
/// 推荐页(礼花 tab)顶部频道导航:频道全部来自分类接口,右侧搜索/礼物。
|
||||
class RecommendChannelNav extends StatefulWidget {
|
||||
const RecommendChannelNav({
|
||||
super.key,
|
||||
required this.categories,
|
||||
required this.selectedId,
|
||||
required this.onSelected,
|
||||
required this.onSearch,
|
||||
this.onGift,
|
||||
});
|
||||
|
||||
static const double height = _navHeight;
|
||||
|
||||
/// 接口返回的内容分类(首项即「精选」)。
|
||||
final List<ContentCategory> categories;
|
||||
final String? selectedId;
|
||||
final ValueChanged<String> onSelected;
|
||||
final VoidCallback onSearch;
|
||||
|
||||
/// 右上角金色礼物盒(钱包入口);为空时不显示。
|
||||
final VoidCallback? onGift;
|
||||
|
||||
@override
|
||||
State<RecommendChannelNav> createState() => _RecommendChannelNavState();
|
||||
}
|
||||
|
||||
class _RecommendChannelNavState extends State<RecommendChannelNav> {
|
||||
static const double _revealLeftInset = 20;
|
||||
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
final GlobalKey _scrollViewKey = GlobalKey();
|
||||
final Map<String, GlobalKey> _tabKeys = {};
|
||||
|
||||
GlobalKey _keyFor(String id) =>
|
||||
_tabKeys.putIfAbsent(id, () => GlobalKey());
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant RecommendChannelNav oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.selectedId != null &&
|
||||
(widget.selectedId != oldWidget.selectedId ||
|
||||
widget.categories.length != oldWidget.categories.length)) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _revealSelected());
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 把选中频道平滑滚动到可视区域:对齐到滚动区左缘 20pt。
|
||||
void _revealSelected() {
|
||||
final id = widget.selectedId;
|
||||
if (id == null || !_scrollController.hasClients) return;
|
||||
final tabCtx = _tabKeys[id]?.currentContext;
|
||||
final tabBox = tabCtx?.findRenderObject() as RenderBox?;
|
||||
final scrollBox =
|
||||
_scrollViewKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
if (tabBox == null || scrollBox == null) return;
|
||||
|
||||
final viewportLeft = scrollBox.localToGlobal(Offset.zero).dx;
|
||||
final tabLeft = tabBox.localToGlobal(Offset.zero).dx;
|
||||
final relative = tabLeft - viewportLeft;
|
||||
final position = _scrollController.position;
|
||||
final target =
|
||||
(position.pixels + relative - _revealLeftInset)
|
||||
.clamp(0.0, position.maxScrollExtent);
|
||||
if ((target - position.pixels).abs() < 0.5) return;
|
||||
_scrollController.animateTo(
|
||||
target,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final lang = Localizations.localeOf(context).languageCode;
|
||||
final categories = widget.categories;
|
||||
final tabs = <(String, String, String)>[
|
||||
for (final category in categories)
|
||||
(category.id, category.localizedName(lang), category.id),
|
||||
];
|
||||
return SizedBox(
|
||||
key: const Key('recommend-channel-nav'),
|
||||
height: RecommendChannelNav.height,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
key: _scrollViewKey,
|
||||
controller: _scrollController,
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.only(left: 20, right: 8),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (var index = 0; index < tabs.length; index++) ...[
|
||||
if (index > 0) const SizedBox(width: _tabGap),
|
||||
KeyedSubtree(
|
||||
key: _keyFor(tabs[index].$3),
|
||||
child: _tab(
|
||||
key: Key('recommend-tab-${tabs[index].$3}'),
|
||||
code: tabs[index].$3,
|
||||
label: tabs[index].$2,
|
||||
selected: widget.selectedId == tabs[index].$1,
|
||||
onTap: () => widget.onSelected(tabs[index].$1),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox.square(
|
||||
dimension: _searchHitSize,
|
||||
child: IconButton(
|
||||
key: const Key('recommend-search'),
|
||||
tooltip: l10n.search,
|
||||
onPressed: widget.onSearch,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints.tightFor(
|
||||
width: _searchHitSize,
|
||||
height: _searchHitSize,
|
||||
),
|
||||
iconSize: _searchIconSize,
|
||||
color: AppColors.textPrimary,
|
||||
icon: const Icon(Icons.search_rounded),
|
||||
),
|
||||
),
|
||||
if (widget.onGift != null)
|
||||
SizedBox.square(
|
||||
dimension: _searchHitSize,
|
||||
child: IconButton(
|
||||
key: const Key('recommend-gift'),
|
||||
tooltip: l10n.wallet,
|
||||
onPressed: widget.onGift,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints.tightFor(
|
||||
width: _searchHitSize,
|
||||
height: _searchHitSize,
|
||||
),
|
||||
iconSize: _giftIconSize,
|
||||
icon: Image.asset(
|
||||
'assets/icon/gift.png',
|
||||
width: _giftIconSize,
|
||||
height: _giftIconSize,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _tab({
|
||||
required Key key,
|
||||
required String code,
|
||||
required String label,
|
||||
required bool selected,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
return GestureDetector(
|
||||
key: key,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// 补偿文字下方的 4pt 间距 + 3pt 下划线,让文字视觉中心与右侧图标对齐。
|
||||
const SizedBox(height: 4 + _underlineHeight),
|
||||
Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
style: TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: _tabFont,
|
||||
fontWeight: selected ? FontWeight.w700 : FontWeight.w400,
|
||||
letterSpacing: 0,
|
||||
shadows: const [
|
||||
Shadow(color: AppColors.mediaBlack, blurRadius: 6),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
key: Key('recommend-tab-$code-underline'),
|
||||
width: _underlineWidth,
|
||||
height: _underlineHeight,
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? AppColors.brandRed : AppColors.transparent,
|
||||
borderRadius: BorderRadius.circular(_underlineHeight / 2),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user