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,396 @@
// 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/services/favorite_controller.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/widgets/remote_image.dart';
import '../../../l10n/app_localizations.dart';
/// 推荐页顶部静态头图轮播:封面 + 居中剧名/简介 + 立即观看/收藏 + 轮播点。
class HomeHeroCarousel extends StatefulWidget {
const HomeHeroCarousel({
super.key,
required this.banners,
required this.dramaFor,
required this.onWatch,
this.active = true,
});
final List<FeedBanner> banners;
/// 按 banner 关联到的剧目;为空时只展示头图。
final Drama? Function(FeedBanner banner) dramaFor;
/// 点击「立即观看」或直接点按头图。
final ValueChanged<Drama> onWatch;
final bool active;
/// 固定轮播高度;后台按该比例(屏宽 : height)出图即可铺满不裁切。
static const double height = 420;
@override
State<HomeHeroCarousel> createState() => _HomeHeroCarouselState();
}
class _HomeHeroCarouselState extends State<HomeHeroCarousel> {
static const Duration _autoInterval = Duration(milliseconds: 4500);
static const Duration _pageDuration = Duration(milliseconds: 480);
static const double _dotSize = 4;
static const double _dotGap = 9;
static const double _ctaHeight = 38;
final PageController _controller = PageController();
Timer? _timer;
int _index = 0;
bool _pressed = false;
bool _tickerEnabled = true;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _syncTimer());
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final enabled = TickerMode.valuesOf(context).enabled;
if (enabled != _tickerEnabled) {
_tickerEnabled = enabled;
_syncTimer();
}
}
@override
void didUpdateWidget(covariant HomeHeroCarousel oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.banners.length != widget.banners.length) {
if (_index >= widget.banners.length) _index = 0;
_syncTimer();
}
if (oldWidget.banners.length != widget.banners.length ||
oldWidget.active != widget.active) {
_syncTimer();
}
}
@override
void dispose() {
_timer?.cancel();
_controller.dispose();
super.dispose();
}
void _syncTimer() {
_timer?.cancel();
_timer = null;
final canAdvance = mounted &&
widget.active &&
_tickerEnabled &&
!_pressed &&
widget.banners.length > 1;
if (!canAdvance) return;
_timer = Timer.periodic(_autoInterval, (_) => _advance());
}
void _advance() {
if (!_controller.hasClients || widget.banners.length < 2) return;
final next = (_index + 1) % widget.banners.length;
_controller.animateToPage(
next,
duration: _pageDuration,
curve: Curves.easeOutCubic,
);
}
@override
Widget build(BuildContext context) {
if (widget.banners.isEmpty) return const SizedBox.shrink();
return SizedBox(
key: const Key('home-hero-carousel'),
height: HomeHeroCarousel.height,
child: Listener(
onPointerDown: (_) {
_pressed = true;
_timer?.cancel();
},
onPointerUp: (_) {
_pressed = false;
_syncTimer();
},
onPointerCancel: (_) {
_pressed = false;
_syncTimer();
},
child: Stack(
fit: StackFit.expand,
children: [
PageView.builder(
controller: _controller,
itemCount: widget.banners.length,
onPageChanged: (value) => setState(() => _index = value),
itemBuilder: (context, index) => _bannerPage(context, index),
),
Positioned(left: 0, right: 0, bottom: 14, child: _dots(context)),
],
),
),
);
}
Widget _bannerPage(BuildContext context, int index) {
final banner = widget.banners[index];
final drama = widget.dramaFor(banner);
return Stack(
fit: StackFit.expand,
children: [
RemoteImage(
banner.imageUrl,
fit: BoxFit.cover,
showFallbackIcon: true,
fallbackColor: AppColors.contentSurface,
),
const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color(0x99000000),
Color(0x00000000),
Color(0x66000000),
Color(0xB3000000),
],
stops: [0, 0.22, 0.62, 1],
),
),
),
if (drama != null)
Align(
alignment: const Alignment(0, 0.6),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 28),
child: _HeroCopy(
key: Key('home-hero-copy-$index'),
drama: drama,
ctaHeight: _ctaHeight,
onWatch: () => widget.onWatch(drama),
),
),
),
if (drama != null)
Positioned.fill(
child: Material(
color: AppColors.transparent,
child: InkWell(
key: Key('home-hero-$index'),
highlightColor: AppColors.transparent,
onTap: () => widget.onWatch(drama),
),
),
),
],
);
}
Widget _dots(BuildContext context) {
if (widget.banners.length < 2) return const SizedBox.shrink();
return ExcludeSemantics(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (var index = 0; index < widget.banners.length; index++)
Container(
key: Key('home-hero-dot-$index'),
width: _dotSize,
height: _dotSize,
margin: const EdgeInsets.symmetric(horizontal: _dotGap / 2),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: index == _index
? AppColors.brandRed
: AppColors.textPrimary.withValues(alpha: 0.4),
),
),
],
),
);
}
}
/// Hero 中部居中文案与按钮组。
class _HeroCopy extends StatelessWidget {
const _HeroCopy({
super.key,
required this.drama,
required this.ctaHeight,
required this.onWatch,
});
final Drama drama;
final double ctaHeight;
final VoidCallback onWatch;
static const Color _titleCream = Color(0xFFE9D3B8);
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final lang = Localizations.localeOf(context).languageCode;
final types = drama.localizedTypeLabels(lang);
final meta = [
if (types.isNotEmpty) types.first,
l10n.totalEpisodesShort(drama.episodeCount),
].join(' · ');
return Column(
key: const Key('home-hero-copy'),
mainAxisSize: MainAxisSize.min,
children: [
Text(
drama.localizedTitle(lang),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: const TextStyle(
color: _titleCream,
fontSize: 28,
fontWeight: FontWeight.w600,
height: 1.15,
shadows: [Shadow(color: Color(0x80000000), blurRadius: 12)],
),
),
const SizedBox(height: 8),
Text(
meta,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: AppColors.textPrimary.withValues(alpha: 0.92),
fontSize: 14,
fontWeight: FontWeight.w400,
),
),
const SizedBox(height: 10),
Text(
drama.localizedSynopsis(lang),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: TextStyle(
color: AppColors.textPrimary.withValues(alpha: 0.95),
fontSize: 15,
fontWeight: FontWeight.w400,
shadows: const [
Shadow(color: Color(0x80000000), blurRadius: 8),
],
),
),
const SizedBox(height: 20),
Row(
mainAxisSize: MainAxisSize.min,
children: [
_WatchButton(height: ctaHeight, label: l10n.watchNowAction, onTap: onWatch),
const SizedBox(width: 12),
_FavoriteCircle(size: ctaHeight, drama: drama),
],
),
],
);
}
}
class _WatchButton extends StatelessWidget {
const _WatchButton({
required this.height,
required this.label,
required this.onTap,
});
final double height;
final String label;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Material(
color: AppColors.brandRed,
borderRadius: BorderRadius.circular(height / 2),
child: InkWell(
key: const Key('home-hero-watch'),
borderRadius: BorderRadius.circular(height / 2),
onTap: onTap,
child: SizedBox(
height: height,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.play_arrow_rounded,
size: 20,
color: AppColors.textPrimary,
),
const SizedBox(width: 2),
Text(
label,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
],
),
),
),
),
);
}
}
class _FavoriteCircle extends StatelessWidget {
const _FavoriteCircle({required this.size, required this.drama});
final double size;
final Drama drama;
@override
Widget build(BuildContext context) {
final favorites = context.watch<FavoriteController>();
final saved = favorites.isSaved(drama.id);
return Material(
color: saved ? AppColors.brandRed : AppColors.transparent,
shape: CircleBorder(
side: BorderSide(
color: saved
? AppColors.brandRed
: AppColors.textPrimary.withValues(alpha: 0.85),
width: 1.5,
),
),
child: InkWell(
key: const Key('home-hero-favorite'),
customBorder: const CircleBorder(),
onTap: () => unawaited(favorites.toggle(drama.id, card: drama)),
child: SizedBox(
width: size,
height: size,
child: Icon(
saved ? Icons.bookmark_rounded : Icons.bookmark_border_rounded,
size: 20,
color: AppColors.textPrimary,
),
),
),
);
}
}
@@ -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,
),
),
],
),
),
],
),
),
),
);
}
}
@@ -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),
),
),
],
),
);
}
}
@@ -3,16 +3,17 @@
import 'package:flutter/material.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../l10n/app_localizations.dart';
import '../recommend_types.dart';
const double _navHeight = 56;
const double _tabGap = 22;
const double _tabGap = 20;
const double _tabFont = 17;
const double _searchHitSize = 40;
const double _searchIconSize = 26;
const double _minNavWidth = 280;
const double _giftIconSize = 30;
const double _underlineWidth = 26;
const double _underlineHeight = 3;
class RecommendTopNav extends StatelessWidget {
const RecommendTopNav({
@@ -21,6 +22,7 @@ class RecommendTopNav extends StatelessWidget {
required this.onSelected,
required this.onSearch,
this.onVideo = false,
this.onGift,
});
static const double height = _navHeight;
@@ -30,6 +32,9 @@ class RecommendTopNav extends StatelessWidget {
final VoidCallback onSearch;
final bool onVideo;
/// 右上角金色礼物盒(钱包入口);为空时不显示。
final VoidCallback? onGift;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
@@ -41,34 +46,16 @@ class RecommendTopNav extends StatelessWidget {
final shadows = onVideo
? const [Shadow(color: AppColors.mediaBlack, blurRadius: 6)]
: null;
return LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth < _minNavWidth) {
return const SizedBox(key: Key('recommend-top-nav'), height: height);
}
return _bar(context, items, shadows);
},
);
}
Widget _bar(
BuildContext context,
List<(RecommendInnerTab, String, String)> items,
List<Shadow>? shadows,
) {
final l10n = AppLocalizations.of(context)!;
return SizedBox(
key: const Key('recommend-top-nav'),
height: height,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: AppDimens.feedPageHorizontal,
),
child: Stack(
alignment: Alignment.center,
children: [
FittedBox(
fit: BoxFit.scaleDown,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.only(left: 20, right: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
@@ -76,6 +63,7 @@ class RecommendTopNav extends StatelessWidget {
if (index > 0) const SizedBox(width: _tabGap),
_tab(
key: Key('recommend-tab-${items[index].$3}'),
code: items[index].$3,
label: items[index].$2,
selected: selected == items[index].$1,
shadows: shadows,
@@ -85,33 +73,53 @@ class RecommendTopNav extends StatelessWidget {
],
),
),
Align(
alignment: Alignment.centerRight,
child: SizedBox.square(
dimension: _searchHitSize,
child: IconButton(
key: const Key('recommend-search'),
tooltip: l10n.search,
onPressed: onSearch,
padding: EdgeInsets.zero,
constraints: const BoxConstraints.tightFor(
width: _searchHitSize,
height: _searchHitSize,
),
iconSize: _searchIconSize,
color: AppColors.textPrimary,
icon: const Icon(Icons.search_rounded),
),
SizedBox.square(
dimension: _searchHitSize,
child: IconButton(
key: const Key('recommend-search'),
tooltip: l10n.search,
onPressed: onSearch,
padding: EdgeInsets.zero,
constraints: const BoxConstraints.tightFor(
width: _searchHitSize,
height: _searchHitSize,
),
iconSize: _searchIconSize,
color: AppColors.textPrimary,
icon: const Icon(Icons.search_rounded),
),
),
if (onGift != null)
SizedBox.square(
dimension: _searchHitSize,
child: IconButton(
key: const Key('recommend-gift'),
tooltip: l10n.wallet,
onPressed: 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 List<Shadow>? shadows,
@@ -121,23 +129,33 @@ class RecommendTopNav extends StatelessWidget {
key: key,
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: SizedBox(
height: height,
child: Center(
child: Text(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// 补偿文字下方的 4pt 间距 + 3pt 下划线,让文字视觉中心与右侧图标对齐。
const SizedBox(height: 4 + _underlineHeight),
Text(
label,
maxLines: 1,
style: TextStyle(
color: selected
? AppColors.textPrimary
: AppColors.textTabInactive,
color: AppColors.textPrimary,
fontSize: _tabFont,
fontWeight: selected ? FontWeight.w700 : FontWeight.w400,
letterSpacing: 0,
shadows: shadows,
),
),
),
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),
),
),
],
),
);
}