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,477 @@
|
||||
// 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) {
|
||||
final lang = Localizations.localeOf(context).languageCode;
|
||||
final badge = AppLocalizations.of(context)!.newArrivalBadge;
|
||||
newArrivals = dramas
|
||||
.where((drama) => _isNewArrivalTag(drama, lang, badge))
|
||||
.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;
|
||||
const cardBodyExtent = 44.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 + cardBodyExtent;
|
||||
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,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 判定接口下发的短剧标签是否为「上新」。
|
||||
///
|
||||
/// 完全以后端返回的 `tag` / `tagLocales`(经 [Drama.localizedTag] 本地化)
|
||||
/// 为准;兼容「上新 / NEW / BARU」三种文案,并兼容「本周上新」这类带前后缀的写法。
|
||||
bool _isNewArrivalTag(Drama drama, String lang, String localizedBadge) {
|
||||
final tag = drama.localizedTag(lang).trim();
|
||||
if (tag.isEmpty) return false;
|
||||
if (tag == localizedBadge) return true;
|
||||
final lower = tag.toLowerCase();
|
||||
return tag.contains('上新') || lower == 'new' || lower == 'baru';
|
||||
}
|
||||
@@ -30,6 +30,7 @@ class RecommendPage extends StatefulWidget {
|
||||
this.catalogApi,
|
||||
required this.active,
|
||||
required this.onSearch,
|
||||
this.onOpenWallet,
|
||||
this.videoOnly = false,
|
||||
this.onImmersiveChanged,
|
||||
});
|
||||
@@ -39,6 +40,9 @@ class RecommendPage extends StatefulWidget {
|
||||
final bool active;
|
||||
final VoidCallback onSearch;
|
||||
|
||||
/// 顶部金色礼物盒(钱包入口)。
|
||||
final VoidCallback? onOpenWallet;
|
||||
|
||||
/// 画中画时只留视频,不画顶栏。
|
||||
final bool videoOnly;
|
||||
final ValueChanged<bool>? onImmersiveChanged;
|
||||
@@ -272,6 +276,7 @@ class _RecommendPageState extends State<RecommendPage>
|
||||
onVideo: _tab == RecommendInnerTab.feed,
|
||||
onSelected: _selectTab,
|
||||
onSearch: _handleSearch,
|
||||
onGift: widget.onOpenWallet,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user