Author SHA1 Message Date
Vicky 709972245f fix(config): release 包固定公网接口地址,不再探测开发机
- release 默认地址固定为 publicBaseUrl(39.162.50.99:8081)
- release 启动跳过环回/局域网探测,避免合并 develop 后请求到开发机 192.168 地址
- debug 行为不变,仍按环回/开发机探测并支持 dart-define 覆盖
2026-10-27 20:10:06 +08:00
Vicky d5d13549f5 chore(merge): 合并推荐频道页与沉浸式首页到 main
- 新增推荐(tab1)接口分类频道页:分类轮播切换、选中频道自动定位、下拉刷新与上拉分页
- 精选频道 banner 轮播 + 接口 tag 上新横滑 + 双列短剧网格,卡片标题统一白色
- 恢复首页(tab0)沉浸式全屏信息流
2026-10-27 20:02:53 +08:00
Vicky c027acc91f feat(recommend): 新增推荐频道页并恢复首页沉浸式信息流
- tab1 推荐页改为接口分类频道:频道导航可横滑,选中频道自动滚动到可视区,内容区左右滑动切换分类
- 精选频道:顶部固定高度 banner 轮播图 + 接口 tag 筛选的上新短剧横滑 + 其余短剧双列网格;其他分类三列网格
- 分类内容支持懒加载、下拉刷新与上拉分页加载
- 卡片角标文案与背景色取自接口 tag/tagColor,短剧标题统一白色
- 恢复 tab0 为沉浸式全屏视频首页及其三 inner-tab 顶导(搜索/礼物入口)
- l10n 新增原创出品、立即观看、上新、编辑精选、全N集等多语言文案
2026-10-27 19:57:42 +08:00
Zhou Weidong 76429628ec chore(merge): 合并播放器切集与双击点赞优化到 main 2026-10-26 19:31:47 +08:00
Zhou Weidong 43f3593218 chore(merge): 合并 iOS 无线数据选择框触发到 main 2026-10-26 09:35:29 +08:00
Zhou Weidong 0eca7ecc31 chore(merge): 合并 iOS 无线数据询问到 main 2026-10-25 21:23:26 +08:00
Zhou Weidong af5d8f9d80 chore(merge): 合并客户端多语言筛选到 main 2026-10-25 19:52:30 +08:00
Zhou Weidong b84a5368d6 chore(merge): 将 release/v1.0.0 同步到 main 2026-10-23 13:07:33 +08:00
Zhou Weidong 67e178c89d chore(merge): 将 develop 同步到 release/v1.0.0 2026-10-23 13:07:33 +08:00
23 changed files with 2002 additions and 294 deletions

No files matched your search

+10 -2
View File
@@ -51,9 +51,12 @@ class AppConfig {
? _runtimeBaseUrl ? _runtimeBaseUrl
: (compiledBaseUrl.isNotEmpty ? compiledBaseUrl : defaultBaseUrl); : (compiledBaseUrl.isNotEmpty ? compiledBaseUrl : defaultBaseUrl);
static final String _defaultBaseUrl = _runsOnDevMachine /// 生产包固定走公网地址;仅 debug 才按平台默认到环回/开发机。
static final String _defaultBaseUrl = kReleaseMode
? publicBaseUrl
: (_runsOnDevMachine
? lanBaseUrl ? lanBaseUrl
: devHostBaseUrl; : devHostBaseUrl);
/// 模拟器、桌面端与开发机同机,环回地址可用;iOS 真机不满足。 /// 模拟器、桌面端与开发机同机,环回地址可用;iOS 真机不满足。
/// Android 没有等价的探测方式,统一按真机处理(模拟器用 /// Android 没有等价的探测方式,统一按真机处理(模拟器用
@@ -93,6 +96,11 @@ class AppConfig {
if (_runtimeBaseUrl.isEmpty) applyRuntimeBaseUrl(compiled); if (_runtimeBaseUrl.isEmpty) applyRuntimeBaseUrl(compiled);
return; return;
} }
// 生产包固定公网地址,不再探测环回/开发机局域网地址,避免请求到开发机。
if (kReleaseMode) {
if (_runtimeBaseUrl.isEmpty) applyRuntimeBaseUrl(publicBaseUrl);
return;
}
final candidates = <String>[ final candidates = <String>[
if (_runtimeBaseUrl.isNotEmpty) _runtimeBaseUrl, if (_runtimeBaseUrl.isNotEmpty) _runtimeBaseUrl,
for (final candidate in _probeCandidates) for (final candidate in _probeCandidates)
+5 -4
View File
@@ -133,9 +133,10 @@ abstract final class AppColors {
/// 底部导航底色。 /// 底部导航底色。
static const bar = ink; static const bar = ink;
/// 底部导航专用:纯黑背景、选中白与未选中灰。 /// 底部导航专用:纯黑背景、选中品牌红与未选中白。
static const navBackground = Color(0xFF000000); static const navBackground = Color(0xFF000000);
static const navSelected = Color(0xFFFFFFFF); static const brandRed = Color(0xFFF04E4E);
static const navSelected = brandRed;
/// 卡片阴影(深色几乎不用阴影)。 /// 卡片阴影(深色几乎不用阴影)。
static const cardShadow = Color(0x00000000); static const cardShadow = Color(0x00000000);
@@ -188,8 +189,8 @@ abstract final class AppColors {
/// 封面集数胶囊。 /// 封面集数胶囊。
static const badgeBeige = raised; static const badgeBeige = raised;
/// 导航未选中。 /// 导航未选中(设计稿为白色线框 + 白色文字)。
static const navUnselected = textSecondary; static const navUnselected = Color(0xFFFFFFFF);
/// 首页内容角标。 /// 首页内容角标。
static const badgeHot = Color(0xFFE5484D); static const badgeHot = Color(0xFFE5484D);
+6 -14
View File
@@ -7,18 +7,12 @@ import 'app_bottom_nav.dart';
/// 客户端根导航的固定顺序。 /// 客户端根导航的固定顺序。
enum AppRootTab { enum AppRootTab {
home( home(),
assetPath: 'assets/icon/film_reel.png',
selectedAssetPath: 'assets/icon/film_reel_filled.png',
),
recommend(), recommend(),
following(), following(),
mine(); mine();
const AppRootTab({this.assetPath, this.selectedAssetPath}); const AppRootTab();
final String? assetPath;
final String? selectedAssetPath;
} }
extension AppRootTabUi on AppRootTab { extension AppRootTabUi on AppRootTab {
@@ -30,15 +24,15 @@ extension AppRootTabUi on AppRootTab {
}; };
IconData get icon => switch (this) { IconData get icon => switch (this) {
AppRootTab.home => Icons.theaters_outlined, AppRootTab.home => Icons.home_outlined,
AppRootTab.recommend => Icons.smart_display_outlined, AppRootTab.recommend => Icons.celebration_outlined,
AppRootTab.following => Icons.bookmark_border_rounded, AppRootTab.following => Icons.bookmark_border_rounded,
AppRootTab.mine => Icons.person_outline_rounded, AppRootTab.mine => Icons.person_outline_rounded,
}; };
IconData get selectedIcon => switch (this) { IconData get selectedIcon => switch (this) {
AppRootTab.home => Icons.theaters, AppRootTab.home => Icons.home_rounded,
AppRootTab.recommend => Icons.smart_display_rounded, AppRootTab.recommend => Icons.celebration_rounded,
AppRootTab.following => Icons.bookmark_rounded, AppRootTab.following => Icons.bookmark_rounded,
AppRootTab.mine => Icons.person_rounded, AppRootTab.mine => Icons.person_rounded,
}; };
@@ -75,8 +69,6 @@ class AppRootBottomNav extends StatelessWidget {
tabKey: tab.tabKey, tabKey: tab.tabKey,
icon: tab.icon, icon: tab.icon,
selectedIcon: tab.selectedIcon, selectedIcon: tab.selectedIcon,
assetPath: tab.assetPath,
selectedAssetPath: tab.selectedAssetPath,
label: tab.label(l10n), label: tab.label(l10n),
selected: tab == selected, selected: tab == selected,
onTap: () => onSelected(tab), onTap: () => onSelected(tab),
+25 -1
View File
@@ -55,12 +55,24 @@
"viewers": {"type": "String"} "viewers": {"type": "String"}
} }
}, },
"watchFullSeries": "Watch full series · {episodes} eps", "watchFullSeries": "Watch full series {episodes} eps",
"@watchFullSeries": { "@watchFullSeries": {
"placeholders": { "placeholders": {
"episodes": {"type": "String"} "episodes": {"type": "String"}
} }
}, },
"feedEpisodePrefix": "EP{episode} | ",
"@feedEpisodePrefix": {
"placeholders": {
"episode": {"type": "int"}
}
},
"feedWatchingCount": "{count} watching",
"@feedWatchingCount": {
"placeholders": {
"count": {"type": "String"}
}
},
"feedTabAnime": "Anime", "feedTabAnime": "Anime",
"feedTabReal": "Live-action", "feedTabReal": "Live-action",
"feedTabForYou": "For you", "feedTabForYou": "For you",
@@ -265,6 +277,18 @@
"pictureInPicture": "Picture in picture", "pictureInPicture": "Picture in picture",
"pipUnavailable": "Picture in picture is unavailable on this device", "pipUnavailable": "Picture in picture is unavailable on this device",
"originalTag": "Original", "originalTag": "Original",
"originalProduction": "Original",
"watchNowAction": "Watch Now",
"newArrivalBadge": "NEW",
"editorPick": "Editor's Pick",
"totalEpisodesShort": "{count} eps",
"@totalEpisodesShort": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"dramaTag": "Short drama", "dramaTag": "Short drama",
"episodeLabel": "Episode {number}", "episodeLabel": "Episode {number}",
"@episodeLabel": { "@episodeLabel": {
+30 -1
View File
@@ -49,7 +49,24 @@
"hotBadge": "Hits", "hotBadge": "Hits",
"filterAll": "Semua", "filterAll": "Semua",
"seriesMeta": "Serial · {episodes} eps | {viewers} menonton", "seriesMeta": "Serial · {episodes} eps | {viewers} menonton",
"watchFullSeries": "Tonton serial lengkap · {episodes} eps", "watchFullSeries": "Tonton serial lengkap {episodes} eps",
"@watchFullSeries": {
"placeholders": {
"episodes": {"type": "String"}
}
},
"feedEpisodePrefix": "EP{episode} | ",
"@feedEpisodePrefix": {
"placeholders": {
"episode": {"type": "int"}
}
},
"feedWatchingCount": "{count} menonton",
"@feedWatchingCount": {
"placeholders": {
"count": {"type": "String"}
}
},
"feedTabAnime": "Anime", "feedTabAnime": "Anime",
"feedTabReal": "Live-action", "feedTabReal": "Live-action",
"feedTabForYou": "Untukmu", "feedTabForYou": "Untukmu",
@@ -239,6 +256,18 @@
"pictureInPicture": "Gambar dalam gambar", "pictureInPicture": "Gambar dalam gambar",
"pipUnavailable": "Gambar dalam gambar tidak tersedia di perangkat ini", "pipUnavailable": "Gambar dalam gambar tidak tersedia di perangkat ini",
"originalTag": "Original", "originalTag": "Original",
"originalProduction": "Original",
"watchNowAction": "Tonton Sekarang",
"newArrivalBadge": "BARU",
"editorPick": "Pilihan Editor",
"totalEpisodesShort": "{count} eps",
"@totalEpisodesShort": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"dramaTag": "Drama pendek", "dramaTag": "Drama pendek",
"episodeLabel": "Episode {number}", "episodeLabel": "Episode {number}",
"@episodeLabel": { "@episodeLabel": {
+43 -1
View File
@@ -397,9 +397,21 @@ abstract class AppLocalizations {
/// No description provided for @watchFullSeries. /// No description provided for @watchFullSeries.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Watch full series · {episodes} eps'** /// **'Watch full series {episodes} eps'**
String watchFullSeries(String episodes); String watchFullSeries(String episodes);
/// No description provided for @feedEpisodePrefix.
///
/// In en, this message translates to:
/// **'EP{episode} | '**
String feedEpisodePrefix(int episode);
/// No description provided for @feedWatchingCount.
///
/// In en, this message translates to:
/// **'{count} watching'**
String feedWatchingCount(String count);
/// No description provided for @feedTabAnime. /// No description provided for @feedTabAnime.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -1198,6 +1210,36 @@ abstract class AppLocalizations {
/// **'Original'** /// **'Original'**
String get originalTag; String get originalTag;
/// No description provided for @originalProduction.
///
/// In en, this message translates to:
/// **'Original'**
String get originalProduction;
/// No description provided for @watchNowAction.
///
/// In en, this message translates to:
/// **'Watch Now'**
String get watchNowAction;
/// No description provided for @newArrivalBadge.
///
/// In en, this message translates to:
/// **'NEW'**
String get newArrivalBadge;
/// No description provided for @editorPick.
///
/// In en, this message translates to:
/// **'Editor\'s Pick'**
String get editorPick;
/// No description provided for @totalEpisodesShort.
///
/// In en, this message translates to:
/// **'{count} eps'**
String totalEpisodesShort(int count);
/// No description provided for @dramaTag. /// No description provided for @dramaTag.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
+28 -1
View File
@@ -162,7 +162,17 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String watchFullSeries(String episodes) { String watchFullSeries(String episodes) {
return 'Watch full series · $episodes eps'; return 'Watch full series $episodes eps';
}
@override
String feedEpisodePrefix(int episode) {
return 'EP$episode | ';
}
@override
String feedWatchingCount(String count) {
return '$count watching';
} }
@override @override
@@ -594,6 +604,23 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get originalTag => 'Original'; String get originalTag => 'Original';
@override
String get originalProduction => 'Original';
@override
String get watchNowAction => 'Watch Now';
@override
String get newArrivalBadge => 'NEW';
@override
String get editorPick => 'Editor\'s Pick';
@override
String totalEpisodesShort(int count) {
return '$count eps';
}
@override @override
String get dramaTag => 'Short drama'; String get dramaTag => 'Short drama';
+28 -1
View File
@@ -162,7 +162,17 @@ class AppLocalizationsId extends AppLocalizations {
@override @override
String watchFullSeries(String episodes) { String watchFullSeries(String episodes) {
return 'Tonton serial lengkap · $episodes eps'; return 'Tonton serial lengkap $episodes eps';
}
@override
String feedEpisodePrefix(int episode) {
return 'EP$episode | ';
}
@override
String feedWatchingCount(String count) {
return '$count menonton';
} }
@override @override
@@ -595,6 +605,23 @@ class AppLocalizationsId extends AppLocalizations {
@override @override
String get originalTag => 'Original'; String get originalTag => 'Original';
@override
String get originalProduction => 'Original';
@override
String get watchNowAction => 'Tonton Sekarang';
@override
String get newArrivalBadge => 'BARU';
@override
String get editorPick => 'Pilihan Editor';
@override
String totalEpisodesShort(int count) {
return '$count eps';
}
@override @override
String get dramaTag => 'Drama pendek'; String get dramaTag => 'Drama pendek';
+28 -1
View File
@@ -159,7 +159,17 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String watchFullSeries(String episodes) { String watchFullSeries(String episodes) {
return '观看完整剧集 · 全$episodes集'; return '观看完整剧集 全$episodes集';
}
@override
String feedEpisodePrefix(int episode) {
return '第$episode集|';
}
@override
String feedWatchingCount(String count) {
return '共$count人在看';
} }
@override @override
@@ -587,6 +597,23 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get originalTag => '原创'; String get originalTag => '原创';
@override
String get originalProduction => '原创出品';
@override
String get watchNowAction => '立即观看';
@override
String get newArrivalBadge => '上新';
@override
String get editorPick => '编辑精选';
@override
String totalEpisodesShort(int count) {
return '全$count集';
}
@override @override
String get dramaTag => '短剧'; String get dramaTag => '短剧';
+30 -1
View File
@@ -49,7 +49,24 @@
"hotBadge": "爆剧", "hotBadge": "爆剧",
"filterAll": "全部", "filterAll": "全部",
"seriesMeta": "系列剧 · 共{episodes}集 | 共{viewers}人在追", "seriesMeta": "系列剧 · 共{episodes}集 | 共{viewers}人在追",
"watchFullSeries": "观看完整剧集 · 全{episodes}集", "watchFullSeries": "观看完整剧集 全{episodes}集",
"@watchFullSeries": {
"placeholders": {
"episodes": {"type": "String"}
}
},
"feedEpisodePrefix": "第{episode}集|",
"@feedEpisodePrefix": {
"placeholders": {
"episode": {"type": "int"}
}
},
"feedWatchingCount": "共{count}人在看",
"@feedWatchingCount": {
"placeholders": {
"count": {"type": "String"}
}
},
"feedTabAnime": "漫剧", "feedTabAnime": "漫剧",
"feedTabReal": "真人剧", "feedTabReal": "真人剧",
"feedTabForYou": "推荐", "feedTabForYou": "推荐",
@@ -239,6 +256,18 @@
"pictureInPicture": "画中画", "pictureInPicture": "画中画",
"pipUnavailable": "此设备无法使用画中画", "pipUnavailable": "此设备无法使用画中画",
"originalTag": "原创", "originalTag": "原创",
"originalProduction": "原创出品",
"watchNowAction": "立即观看",
"newArrivalBadge": "上新",
"editorPick": "编辑精选",
"totalEpisodesShort": "全{count}集",
"@totalEpisodesShort": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"dramaTag": "短剧", "dramaTag": "短剧",
"episodeLabel": "第 {number} 集", "episodeLabel": "第 {number} 集",
"@episodeLabel": { "@episodeLabel": {
+12 -34
View File
@@ -12,7 +12,6 @@ import '../../core/data/services/home_feed_controller.dart';
import '../../core/data/services/catalog_controller.dart'; import '../../core/data/services/catalog_controller.dart';
import '../../core/data/services/category_controller.dart'; import '../../core/data/services/category_controller.dart';
import '../../core/data/services/favorite_controller.dart'; import '../../core/data/services/favorite_controller.dart';
import '../../core/data/services/wallet_controller.dart';
import '../../core/platform/haptics.dart'; import '../../core/platform/haptics.dart';
import '../../core/theme/app_colors.dart'; import '../../core/theme/app_colors.dart';
import '../../core/theme/app_theme.dart'; import '../../core/theme/app_theme.dart';
@@ -20,16 +19,13 @@ import '../../core/widgets/auth_gate.dart';
import '../../core/widgets/app_root_tab.dart'; import '../../core/widgets/app_root_tab.dart';
import '../discover/search_page.dart'; import '../discover/search_page.dart';
import '../following/following_page.dart'; import '../following/following_page.dart';
import '../filter/filter_page.dart';
import '../mine/mine_types.dart'; import '../mine/mine_types.dart';
import '../recommend/recommend_page.dart'; import '../recommend/recommend_page.dart';
import '../recommend/recommend_discover_page.dart';
import '../mine/mine_view.dart'; import '../mine/mine_view.dart';
import '../store/vip_page.dart';
import '../wallet/wallet_page.dart'; import '../wallet/wallet_page.dart';
import 'home_binding.dart'; import 'home_binding.dart';
import 'home_logic.dart'; import 'home_logic.dart';
import 'widgets/home_feed_grid.dart';
import 'widgets/home_grid_header.dart';
class HomePage extends StatefulWidget { class HomePage extends StatefulWidget {
const HomePage({super.key}); const HomePage({super.key});
@@ -176,8 +172,8 @@ class _HomeTabsState extends State<_HomeTabs> {
Widget _body(BuildContext context) { Widget _body(BuildContext context) {
final pages = <Widget>[ final pages = <Widget>[
_homeGrid(context),
_recommend(context), _recommend(context),
_recommendDiscover(context),
FollowingPage( FollowingPage(
onGoHome: () => logic.select(0), onGoHome: () => logic.select(0),
favoriteController: context.read<FavoriteController>(), favoriteController: context.read<FavoriteController>(),
@@ -194,27 +190,23 @@ class _HomeTabsState extends State<_HomeTabs> {
); );
} }
Widget _homeGrid(BuildContext context) {
return HomeFeedGrid(
feedController: widget.feedController,
callbacks: HomeGridCallbacks(
onSearch: () => _openSearch(context),
onOpenVip: () => unawaited(_openVip(context)),
onOpenWallet: () => unawaited(_openWallet(context)),
onOpenFilter: () => _pushPage(context, const FilterPage()),
),
onOpenDrama: (drama) => unawaited(logic.playDrama(context, drama)),
);
}
Widget _recommend(BuildContext context) { Widget _recommend(BuildContext context) {
return RecommendPage( return RecommendPage(
feedController: widget.feedController, feedController: widget.feedController,
catalogApi: _catalogApi(context), catalogApi: _catalogApi(context),
active: logic.tab == 1, active: logic.tab == 0,
videoOnly: logic.videoImmersive, videoOnly: logic.videoImmersive,
onImmersiveChanged: logic.onImmersiveChanged, onImmersiveChanged: logic.onImmersiveChanged,
onSearch: () => _openSearch(context), onSearch: () => _openSearch(context),
onOpenWallet: () => unawaited(_openWallet(context)),
);
}
Widget _recommendDiscover(BuildContext context) {
return RecommendDiscoverPage(
active: logic.tab == 1,
onSearch: () => _openSearch(context),
onOpenWallet: () => unawaited(_openWallet(context)),
); );
} }
@@ -232,20 +224,6 @@ class _HomeTabsState extends State<_HomeTabs> {
).push<void>(MaterialPageRoute(builder: (_) => const SearchPage())); ).push<void>(MaterialPageRoute(builder: (_) => const SearchPage()));
} }
void _pushPage(BuildContext context, Widget page) {
Navigator.of(context).push<void>(MaterialPageRoute(builder: (_) => page));
}
Future<void> _openVip(BuildContext context) async {
if (!await ensureSignedIn(context) || !context.mounted) return;
final wallet = context.read<WalletController>();
await Navigator.of(context).push<void>(
MaterialPageRoute(
builder: (_) => VipPage(onPurchased: () => unawaited(wallet.load())),
),
);
}
Future<void> _openWallet(BuildContext context) async { Future<void> _openWallet(BuildContext context) async {
if (!await ensureSignedIn(context) || !context.mounted) return; if (!await ensureSignedIn(context) || !context.mounted) return;
await Navigator.of( await Navigator.of(
+1 -1
View File
@@ -50,7 +50,7 @@ class HomeFeedCard extends StatelessWidget {
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
textAlign: TextAlign.left, textAlign: TextAlign.left,
style: const TextStyle( style: const TextStyle(
color: AppColors.navSelected, color: AppColors.textPrimary,
fontSize: AppDimens.cardTitleFont, fontSize: AppDimens.cardTitleFont,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
height: AppDimens.cardTitleLineHeight, height: AppDimens.cardTitleLineHeight,
+1
View File
@@ -1200,6 +1200,7 @@ class _PlayerPageState extends State<PlayerPage>
unawaited(_presentFullPlayer(context, index)); unawaited(_presentFullPlayer(context, index));
}, },
onEpisodes: () => logic.showEpisodes(context), onEpisodes: () => logic.showEpisodes(context),
onOpenSearch: widget.onSearch,
onMore: _openCurrentMore, onMore: _openCurrentMore,
onScrubStart: videoReady onScrubStart: videoReady
? () => logic.beginScrub(videoPort!) ? () => logic.beginScrub(videoPort!)
@@ -22,11 +22,13 @@ part 'home_feed_overlay_parts.dart';
const double _progressHeight = 2; const double _progressHeight = 2;
const double _progressHitHeight = 32; const double _progressHitHeight = 32;
const double _pageHorizontal = 14; const double _pageHorizontal = 20;
const double _railRight = 8; const double _railRight = 14;
const double _railGap = 12; const double _railGap = 12;
const double _bottomInfoInset = 32; // 观看条高 44;2pt 进度线视觉上沿距观看条顶约 7pt(线中心距进度条盒底 8)。
const double _railBottomOffset = 60; // 信息面板底边取 44+16=60,使简介↔进度线、进度线↔观看条两侧间距均为 7pt。
const double _bottomInfoInset = homeFeedWatchAllHeight + 16;
const double _railBottomOffset = 40;
const Duration _overlayFadeDuration = Duration(milliseconds: 120); const Duration _overlayFadeDuration = Duration(milliseconds: 120);
const List<Shadow> _boostTextShadows = [ const List<Shadow> _boostTextShadows = [
Shadow(color: AppColors.shadow, blurRadius: 5, offset: Offset(0, 1)), Shadow(color: AppColors.shadow, blurRadius: 5, offset: Offset(0, 1)),
@@ -52,6 +54,7 @@ class HomeFeedOverlay extends StatefulWidget {
required this.onDetail, required this.onDetail,
required this.onOpenFullPlayer, required this.onOpenFullPlayer,
this.onEpisodes, this.onEpisodes,
this.onOpenSearch,
required this.onTogglePlay, required this.onTogglePlay,
required this.onBoostStart, required this.onBoostStart,
required this.onBoostEnd, required this.onBoostEnd,
@@ -92,6 +95,9 @@ class HomeFeedOverlay extends StatefulWidget {
final VoidCallback onDetail; final VoidCallback onDetail;
final VoidCallback onOpenFullPlayer; final VoidCallback onOpenFullPlayer;
final VoidCallback? onEpisodes; final VoidCallback? onEpisodes;
/// 底部热搜胶囊点击(降级版:跳搜索页)。
final VoidCallback? onOpenSearch;
final VoidCallback onTogglePlay; final VoidCallback onTogglePlay;
final VoidCallback onBoostStart; final VoidCallback onBoostStart;
final VoidCallback onBoostEnd; final VoidCallback onBoostEnd;
@@ -173,7 +179,7 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
Positioned( Positioned(
left: 0, left: 0,
right: 0, right: 0,
bottom: 20, bottom: homeFeedWatchAllHeight + 12,
child: AnimatedOpacity( child: AnimatedOpacity(
duration: _overlayFadeDuration, duration: _overlayFadeDuration,
opacity: widget.boosting ? 1 : 0, opacity: widget.boosting ? 1 : 0,
@@ -184,9 +190,9 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
), ),
), ),
Positioned( Positioned(
left: 0, left: _pageHorizontal,
right: 0, right: _pageHorizontal,
bottom: 0, bottom: homeFeedWatchAllHeight,
child: _FeedProgressBar( child: _FeedProgressBar(
controller: widget.controller, controller: widget.controller,
locked: widget.locked, locked: widget.locked,
@@ -203,6 +209,24 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
onSeek: widget.onSeek, onSeek: widget.onSeek,
), ),
), ),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: AnimatedOpacity(
duration: _overlayFadeDuration,
opacity: widget.visible && !_scrubbing && !widget.boosting
? 1
: 0,
child: IgnorePointer(
ignoring: !widget.visible || _scrubbing || widget.boosting,
child: HomeFeedWatchAllBar(
drama: widget.drama,
onTap: widget.onOpenFullPlayer,
),
),
),
),
], ],
), ),
); );
@@ -273,7 +297,7 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
onSave: widget.onSave, onSave: widget.onSave,
onLike: widget.onLike, onLike: widget.onLike,
onComments: widget.onComments, onComments: widget.onComments,
onEpisodes: widget.onEpisodes ?? widget.onOpenFullPlayer, onShare: widget.onShare,
); );
} }
@@ -283,6 +307,7 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
episode: widget.episode, episode: widget.episode,
onDetail: widget.onDetail, onDetail: widget.onDetail,
onOpenFullPlayer: widget.onOpenFullPlayer, onOpenFullPlayer: widget.onOpenFullPlayer,
onOpenSearch: widget.onOpenSearch,
); );
} }
} }
@@ -6,7 +6,6 @@ const double _thumbSize = 6;
const double _thumbActiveSize = 14; const double _thumbActiveSize = 14;
const double _trackActiveHeight = 6; const double _trackActiveHeight = 6;
const double _lineCenterFromBottom = 8; const double _lineCenterFromBottom = 8;
const Duration _trackResizeDuration = Duration(milliseconds: 120);
const double _timeBubbleWidth = 96; const double _timeBubbleWidth = 96;
const double _timeBubbleMargin = 12; const double _timeBubbleMargin = 12;
const Key _inactiveTrackKey = Key('home-feed-progress-inactive'); const Key _inactiveTrackKey = Key('home-feed-progress-inactive');
@@ -371,10 +370,8 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
children: [ children: [
Align( Align(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: AnimatedContainer( child: Container(
key: const Key('home-feed-progress-track'), key: const Key('home-feed-progress-track'),
duration: _trackResizeDuration,
curve: Curves.easeOut,
width: width, width: width,
height: trackHeight, height: trackHeight,
color: AppColors.border, color: AppColors.border,
@@ -382,10 +379,8 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
), ),
Align( Align(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: AnimatedContainer( child: Container(
key: const Key('home-feed-progress-played'), key: const Key('home-feed-progress-played'),
duration: dragging ? Duration.zero : _trackResizeDuration,
curve: Curves.easeOut,
width: playedWidth, width: playedWidth,
height: trackHeight, height: trackHeight,
color: AppColors.blue, color: AppColors.blue,
@@ -401,10 +396,8 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
child: SizedBox.square( child: SizedBox.square(
dimension: _thumbActiveSize, dimension: _thumbActiveSize,
child: Center( child: Center(
child: AnimatedContainer( child: Container(
key: const Key('home-feed-progress-thumb'), key: const Key('home-feed-progress-thumb'),
duration: _trackResizeDuration,
curve: Curves.easeOut,
width: size, width: size,
height: size, height: size,
decoration: const BoxDecoration( decoration: const BoxDecoration(
@@ -1,8 +1,7 @@
// Author: fruit.com.cn // Author: fruit.com.cn
import 'dart:math' as math;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../../core/data/models/drama.dart'; import '../../../core/data/models/drama.dart';
import '../../../core/formatting/localized_content.dart'; import '../../../core/formatting/localized_content.dart';
@@ -11,15 +10,22 @@ import '../../../l10n/app_localizations.dart';
import '../../home/widgets/home_drama_classification.dart'; import '../../home/widgets/home_drama_classification.dart';
import 'interaction_rail.dart'; import 'interaction_rail.dart';
const double _seriesFont = 19; const double _seriesFont = 21;
const double _tagFont = 13; const double _tagFont = 14;
const double _synopsisFont = 13; const double _synopsisFont = 15;
const double _watchAllHeight = 44; const double _watchAllHeight = 44;
const List<Shadow> _textShadows = [ const List<Shadow> _textShadows = [
Shadow(color: AppColors.shadow, blurRadius: 6, offset: Offset(0, 1)), Shadow(color: AppColors.shadow, blurRadius: 6, offset: Offset(0, 1)),
]; ];
final RegExp _tagSplitter = RegExp(r'[,/、·]'); final RegExp _tagSplitter = RegExp(r'[,/、·]');
/// 通栏「观看完整剧集」半透明白底条高度,overlay 用它安排底部避让。
const double homeFeedWatchAllHeight = _watchAllHeight;
/// 播放量紧凑展示(跟随语言):zh 20万 / en 200K / id 200 rb。
String _compactViewers(int count, Locale locale) =>
NumberFormat.compact(locale: locale.languageCode).format(count);
class HomeFeedInfoPanel extends StatefulWidget { class HomeFeedInfoPanel extends StatefulWidget {
const HomeFeedInfoPanel({ const HomeFeedInfoPanel({
super.key, super.key,
@@ -27,6 +33,7 @@ class HomeFeedInfoPanel extends StatefulWidget {
required this.episode, required this.episode,
required this.onDetail, required this.onDetail,
required this.onOpenFullPlayer, required this.onOpenFullPlayer,
this.onOpenSearch,
}); });
final Drama drama; final Drama drama;
@@ -34,6 +41,9 @@ class HomeFeedInfoPanel extends StatefulWidget {
final VoidCallback onDetail; final VoidCallback onDetail;
final VoidCallback onOpenFullPlayer; final VoidCallback onOpenFullPlayer;
/// 热搜胶囊点击;为空或播放量为 0 时不显示胶囊。
final VoidCallback? onOpenSearch;
@override @override
State<HomeFeedInfoPanel> createState() => _HomeFeedInfoPanelState(); State<HomeFeedInfoPanel> createState() => _HomeFeedInfoPanelState();
} }
@@ -48,9 +58,22 @@ class _HomeFeedInfoPanelState extends State<HomeFeedInfoPanel> {
final synopsis = localizedDramaSynopsis(l10n, widget.drama).trim(); final synopsis = localizedDramaSynopsis(l10n, widget.drama).trim();
final genres = _genreValues(Localizations.localeOf(context).languageCode); final genres = _genreValues(Localizations.localeOf(context).languageCode);
return Column( return Column(
key: const Key('home-feed-info-panel'),
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
if (widget.onOpenSearch != null && widget.drama.viewCount > 0) ...[
_HotSearchPill(
label: l10n.feedWatchingCount(
_compactViewers(
widget.drama.viewCount,
Localizations.localeOf(context),
),
),
onTap: widget.onOpenSearch!,
),
const SizedBox(height: 8),
],
GestureDetector( GestureDetector(
key: const Key('home-feed-series-name'), key: const Key('home-feed-series-name'),
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
@@ -67,12 +90,12 @@ class _HomeFeedInfoPanelState extends State<HomeFeedInfoPanel> {
style: const TextStyle( style: const TextStyle(
color: AppColors.textPrimary, color: AppColors.textPrimary,
fontSize: _seriesFont, fontSize: _seriesFont,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w700,
shadows: _textShadows, shadows: _textShadows,
), ),
), ),
), ),
const SizedBox(width: 4), const SizedBox(width: 2),
const Icon( const Icon(
Icons.chevron_right_rounded, Icons.chevron_right_rounded,
size: 20, size: 20,
@@ -81,119 +104,96 @@ class _HomeFeedInfoPanelState extends State<HomeFeedInfoPanel> {
], ],
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 6),
_tagMetaRow(l10n, genres), _tagMetaRow(l10n, genres),
if (synopsis.isNotEmpty) ...[ if (synopsis.isNotEmpty) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
_synopsis(l10n, synopsis), _synopsis(l10n, synopsis),
], ],
const SizedBox(height: 12),
_watchAll(context, l10n),
], ],
); );
} }
Widget _tagMetaRow(AppLocalizations l10n, List<String> genres) { Widget _tagMetaRow(AppLocalizations l10n, List<String> genres) {
return Row( final chips = <Widget>[
key: const Key('home-feed-tags'), if (isHotDrama(widget.drama))
children: [ _TagChip(
if (isHotDrama(widget.drama)) ...[
Container(
key: const Key('home-feed-hot-badge'), key: const Key('home-feed-hot-badge'),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), label: l10n.homeBadgeHot,
decoration: BoxDecoration( filled: true,
color: AppColors.error,
borderRadius: BorderRadius.circular(3),
), ),
child: Text( for (final genre in genres)
l10n.homeBadgeHot, _TagChip(key: Key('home-feed-tag-$genre'), label: genre),
style: const TextStyle( ];
color: AppColors.textPrimary, return SizedBox(
fontSize: 10, key: const Key('home-feed-tags'),
fontWeight: FontWeight.w600, height: 25,
child: ListView.separated(
scrollDirection: Axis.horizontal,
physics: const NeverScrollableScrollPhysics(),
shrinkWrap: true,
itemCount: chips.length,
separatorBuilder: (_, _) => const SizedBox(width: 8),
itemBuilder: (_, index) => chips[index],
), ),
),
),
const SizedBox(width: 8),
],
Flexible(
fit: FlexFit.loose,
child: Text(
genres.join(' '),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.overlayLabel,
fontSize: _tagFont,
),
),
),
const SizedBox(width: 8),
Flexible(
child: Text(
l10n.favoriteEpisodeProgress(
widget.episode.number,
widget.drama.episodeCount,
),
key: const Key('home-feed-episode-progress'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.overlayLabel,
fontSize: _tagFont,
),
),
),
],
); );
} }
Widget _synopsis(AppLocalizations l10n, String synopsis) { Widget _synopsis(AppLocalizations l10n, String synopsis) {
const style = TextStyle( final prefix = l10n.feedEpisodePrefix(widget.episode.number);
color: AppColors.overlaySecondary, final baseStyle = const TextStyle(
color: AppColors.textPrimary,
fontSize: _synopsisFont, fontSize: _synopsisFont,
height: 1.35, height: 1.3,
shadows: _textShadows, shadows: _textShadows,
); );
final span = TextSpan(
children: [
TextSpan(
text: prefix,
style: baseStyle.copyWith(fontWeight: FontWeight.w600),
),
TextSpan(text: synopsis, style: baseStyle),
],
);
if (_synopsisExpanded) {
return _expandedSynopsis(span, baseStyle, l10n);
}
return LayoutBuilder( return LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final toggle = _synopsisToggle(
key: const Key('home-feed-synopsis-more'),
label: l10n.expandSynopsis,
);
final painter = TextPainter( final painter = TextPainter(
text: TextSpan(text: synopsis, style: style), text: span,
maxLines: 2, maxLines: 1,
textDirection: Directionality.of(context), textDirection: Directionality.of(context),
textScaler: MediaQuery.textScalerOf(context), textScaler: MediaQuery.textScalerOf(context),
)..layout(maxWidth: constraints.maxWidth); )..layout(maxWidth: constraints.maxWidth);
final overflows = painter.didExceedMaxLines; if (!painter.didExceedMaxLines) {
if (!overflows) { return Text.rich(
return Text( span,
synopsis,
key: const Key('home-feed-synopsis'), key: const Key('home-feed-synopsis'),
style: style, maxLines: 1,
style: baseStyle,
); );
} }
if (_synopsisExpanded) { // 设计稿:单行省略号 + 行内固定「展开」。
return _expandedSynopsis(constraints.maxWidth, synopsis, style, l10n); return Row(
} key: const Key('home-feed-synopsis-collapsed'),
return Stack(
children: [ children: [
Padding( Expanded(
padding: const EdgeInsets.only(right: 40), child: Text.rich(
child: Text( span,
synopsis,
key: const Key('home-feed-synopsis'), key: const Key('home-feed-synopsis'),
maxLines: 2, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: style, style: baseStyle,
),
),
Positioned(
right: 0,
bottom: 0,
child: _synopsisToggle(
key: const Key('home-feed-synopsis-more'),
label: l10n.expandSynopsis,
), ),
), ),
const SizedBox(width: 2),
toggle,
], ],
); );
}, },
@@ -201,17 +201,20 @@ class _HomeFeedInfoPanelState extends State<HomeFeedInfoPanel> {
} }
Widget _expandedSynopsis( Widget _expandedSynopsis(
double maxWidth, InlineSpan span,
String synopsis,
TextStyle style, TextStyle style,
AppLocalizations l10n, AppLocalizations l10n,
) { ) {
return SizedBox( return Column(
width: maxWidth, key: const Key('home-feed-synopsis-expanded'),
child: Column( crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Text(synopsis, key: const Key('home-feed-synopsis'), style: style), Text.rich(
span,
key: const Key('home-feed-synopsis'),
style: style,
),
const SizedBox(height: 2),
Align( Align(
alignment: Alignment.centerRight, alignment: Alignment.centerRight,
child: _synopsisToggle( child: _synopsisToggle(
@@ -220,7 +223,6 @@ class _HomeFeedInfoPanelState extends State<HomeFeedInfoPanel> {
), ),
), ),
], ],
),
); );
} }
@@ -234,54 +236,12 @@ class _HomeFeedInfoPanelState extends State<HomeFeedInfoPanel> {
color: AppColors.textPrimary, color: AppColors.textPrimary,
fontSize: _synopsisFont, fontSize: _synopsisFont,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
shadows: _textShadows,
), ),
), ),
); );
} }
Widget _watchAll(BuildContext context, AppLocalizations l10n) {
final screenWidth = MediaQuery.sizeOf(context).width;
return LayoutBuilder(
builder: (context, constraints) {
final buttonWidth = math.min(screenWidth * 0.7, constraints.maxWidth);
return Align(
alignment: Alignment.centerLeft,
child: SizedBox(
width: buttonWidth,
height: _watchAllHeight,
child: DecoratedBox(
key: const Key('home-feed-full-series-bg'),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [AppColors.actionRed, AppColors.blue],
),
borderRadius: BorderRadius.circular(8),
),
child: Material(
color: AppColors.transparent,
child: InkWell(
key: const Key('home-feed-full-series'),
onTap: widget.onOpenFullPlayer,
borderRadius: BorderRadius.circular(8),
child: Center(
child: Text(
l10n.homeWatchAll,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
),
),
),
),
),
);
},
);
}
List<String> _genreValues(String languageCode) { List<String> _genreValues(String languageCode) {
final localized = widget.drama.localizedTypeLabels(languageCode); final localized = widget.drama.localizedTypeLabels(languageCode);
if (localized.isNotEmpty) return localized.take(3).toList(growable: false); if (localized.isNotEmpty) return localized.take(3).toList(growable: false);
@@ -297,6 +257,147 @@ class _HomeFeedInfoPanelState extends State<HomeFeedInfoPanel> {
} }
} }
/// 热搜榜降级胶囊:🔍 共 xxx 人在看 ›(暂无榜单接口,点击跳搜索页)。
class _HotSearchPill extends StatelessWidget {
const _HotSearchPill({required this.label, required this.onTap});
final String label;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
key: const Key('home-feed-hot-search-pill'),
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: AppColors.textPrimary.withValues(alpha: 0.14),
borderRadius: BorderRadius.circular(6),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.search_rounded,
size: 15,
color: AppColors.textPrimary,
),
const SizedBox(width: 5),
Text(
label,
maxLines: 1,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
const SizedBox(width: 2),
const Icon(
Icons.chevron_right_rounded,
size: 16,
color: AppColors.textPrimary,
),
],
),
),
);
}
}
/// 热剧红胶囊 / 类型深色半透明填充胶囊。
class _TagChip extends StatelessWidget {
const _TagChip({super.key, required this.label, this.filled = false});
final String label;
final bool filled;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 2),
alignment: Alignment.center,
decoration: BoxDecoration(
color: filled
? AppColors.brandRed
: AppColors.textPrimary.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(4),
),
child: Text(
label,
maxLines: 1,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: _tagFont,
fontWeight: FontWeight.w500,
height: 1.2,
shadows: _textShadows,
),
),
);
}
}
/// 底部通栏条:🎬 观看完整剧集 全 N 集。
class HomeFeedWatchAllBar extends StatelessWidget {
const HomeFeedWatchAllBar({
super.key,
required this.drama,
required this.onTap,
});
final Drama drama;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Material(
key: const Key('home-feed-full-series-bg'),
color: AppColors.textPrimary.withValues(alpha: 0.15),
child: InkWell(
key: const Key('home-feed-full-series'),
onTap: onTap,
child: SizedBox(
height: _watchAllHeight,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(
children: [
const Icon(
Icons.movie_rounded,
size: 22,
color: AppColors.onPrimary,
),
const SizedBox(width: 10),
Expanded(
child: Text(
l10n.watchFullSeries('${drama.episodeCount}'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: 15,
fontWeight: FontWeight.w500,
),
),
),
const Icon(
Icons.chevron_right_rounded,
size: 22,
color: AppColors.textPrimary,
),
],
),
),
),
),
);
}
}
class HomeFeedActionRail extends StatelessWidget { class HomeFeedActionRail extends StatelessWidget {
const HomeFeedActionRail({ const HomeFeedActionRail({
super.key, super.key,
@@ -305,7 +406,7 @@ class HomeFeedActionRail extends StatelessWidget {
required this.onSave, required this.onSave,
required this.onLike, required this.onLike,
required this.onComments, required this.onComments,
required this.onEpisodes, required this.onShare,
this.favoriteCount, this.favoriteCount,
this.likeCount, this.likeCount,
this.commentCount, this.commentCount,
@@ -319,13 +420,17 @@ class HomeFeedActionRail extends StatelessWidget {
final VoidCallback onSave; final VoidCallback onSave;
final VoidCallback onLike; final VoidCallback onLike;
final VoidCallback onComments; final VoidCallback onComments;
final VoidCallback onEpisodes; final VoidCallback onShare;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
return InteractionRail( return InteractionRail(
itemGap: 14, itemGap: 18,
buttonSize: 48,
iconSize: 36,
countFontSize: 15,
iconCountGap: 6,
items: [ items: [
InteractionRailItem( InteractionRailItem(
key: const Key('rail-save'), key: const Key('rail-save'),
@@ -334,18 +439,9 @@ class HomeFeedActionRail extends StatelessWidget {
count: favoriteCount, count: favoriteCount,
countKey: const Key('rail-save-count'), countKey: const Key('rail-save-count'),
selected: saved, selected: saved,
selectedColor: AppColors.brandRed,
onTap: onSave, onTap: onSave,
), ),
InteractionRailItem(
key: const Key('rail-like'),
asset: 'assets/icon/rail/rail_like.png',
label: l10n.like,
count: likeCount,
countKey: const Key('rail-like-count'),
selected: liked,
selectedColor: AppColors.doubleTapHeartRed,
onTap: onLike,
),
InteractionRailItem( InteractionRailItem(
key: const Key('rail-comments'), key: const Key('rail-comments'),
asset: 'assets/icon/rail/rail_comment.png', asset: 'assets/icon/rail/rail_comment.png',
@@ -355,11 +451,22 @@ class HomeFeedActionRail extends StatelessWidget {
onTap: onComments, onTap: onComments,
), ),
InteractionRailItem( InteractionRailItem(
key: const Key('rail-episodes'), key: const Key('rail-like'),
icon: Icons.layers_rounded, asset: 'assets/icon/rail/rail_like.png',
label: l10n.episodes, label: l10n.like,
countKey: const Key('rail-episodes-label'), count: likeCount,
onTap: onEpisodes, countKey: const Key('rail-like-count'),
selected: liked,
selectedColor: AppColors.brandRed,
onTap: onLike,
),
InteractionRailItem(
key: const Key('rail-share'),
icon: Icons.reply_rounded,
mirrorIcon: true,
label: l10n.share,
countKey: const Key('rail-share-label'),
onTap: onShare,
), ),
], ],
); );
+48 -20
View File
@@ -5,8 +5,10 @@ import 'package:flutter/material.dart';
import '../../../core/formatting/count_format.dart'; import '../../../core/formatting/count_format.dart';
import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_colors.dart';
const double _buttonSize = 44; const double _defaultButtonSize = 44;
const double _iconSize = 32; const double _defaultIconSize = 32;
const double _defaultCountFont = 12;
const double _defaultIconCountGap = 2;
const double _defaultItemGap = 24; const double _defaultItemGap = 24;
/// 互动栏单项:图标、计数与点击。 /// 互动栏单项:图标、计数与点击。
@@ -21,6 +23,7 @@ class InteractionRailItem {
this.countKey, this.countKey,
this.selected = false, this.selected = false,
this.selectedColor = AppColors.blue, this.selectedColor = AppColors.blue,
this.mirrorIcon = false,
}) : assert((asset == null) != (icon == null)); }) : assert((asset == null) != (icon == null));
final Key key; final Key key;
@@ -32,6 +35,9 @@ class InteractionRailItem {
final Key? countKey; final Key? countKey;
final bool selected; final bool selected;
final Color selectedColor; final Color selectedColor;
/// 水平镜像图标(分享弯箭头用 reply 镜像得到)。
final bool mirrorIcon;
} }
/// 播放页与首页 feed 共用的右侧互动栏:收藏/评论/喜欢/分享。 /// 播放页与首页 feed 共用的右侧互动栏:收藏/评论/喜欢/分享。
@@ -40,10 +46,18 @@ class InteractionRail extends StatelessWidget {
super.key, super.key,
required this.items, required this.items,
this.itemGap = _defaultItemGap, this.itemGap = _defaultItemGap,
this.buttonSize = _defaultButtonSize,
this.iconSize = _defaultIconSize,
this.countFontSize = _defaultCountFont,
this.iconCountGap = _defaultIconCountGap,
}); });
final List<InteractionRailItem> items; final List<InteractionRailItem> items;
final double itemGap; final double itemGap;
final double buttonSize;
final double iconSize;
final double countFontSize;
final double iconCountGap;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -52,7 +66,13 @@ class InteractionRail extends StatelessWidget {
children: [ children: [
for (var index = 0; index < items.length; index++) ...[ for (var index = 0; index < items.length; index++) ...[
if (index > 0) SizedBox(height: itemGap), if (index > 0) SizedBox(height: itemGap),
_RailButton(item: items[index]), _RailButton(
item: items[index],
buttonSize: buttonSize,
iconSize: iconSize,
countFontSize: countFontSize,
iconCountGap: iconCountGap,
),
], ],
], ],
); );
@@ -60,53 +80,61 @@ class InteractionRail extends StatelessWidget {
} }
class _RailButton extends StatelessWidget { class _RailButton extends StatelessWidget {
const _RailButton({required this.item}); const _RailButton({
required this.item,
required this.buttonSize,
required this.iconSize,
required this.countFontSize,
required this.iconCountGap,
});
final InteractionRailItem item; final InteractionRailItem item;
final double buttonSize;
final double iconSize;
final double countFontSize;
final double iconCountGap;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final tint = item.selected ? item.selectedColor : AppColors.onPrimary;
return Column( return Column(
key: item.key, key: item.key,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
SizedBox.square( SizedBox.square(
dimension: _buttonSize, dimension: buttonSize,
child: IconButton( child: IconButton(
onPressed: item.onTap, onPressed: item.onTap,
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
icon: item.asset != null icon: item.asset != null
? Image.asset( ? Image.asset(
item.asset!, item.asset!,
width: _iconSize, width: iconSize,
height: _iconSize, height: iconSize,
fit: BoxFit.contain, fit: BoxFit.contain,
color: item.selected color: tint,
? item.selectedColor
: AppColors.onPrimary,
colorBlendMode: BlendMode.srcIn, colorBlendMode: BlendMode.srcIn,
) )
: Icon( : Transform.flip(
item.icon, flipX: item.mirrorIcon,
size: _iconSize, child: Icon(item.icon, size: iconSize, color: tint),
color: item.selected
? item.selectedColor
: AppColors.onPrimary,
), ),
), ),
), ),
const SizedBox(height: 2), SizedBox(height: iconCountGap),
Text( Text(
item.count == null ? item.label : formatInteractionCount(item.count!), item.count == null ? item.label : formatInteractionCount(item.count!),
key: item.countKey, key: item.countKey,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle( style: TextStyle(
color: AppColors.onPrimary, color: AppColors.onPrimary,
height: 1, height: 1,
fontSize: 12, fontSize: countFontSize,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
shadows: [Shadow(blurRadius: 6, color: AppColors.mediaBlack)], shadows: const [
Shadow(blurRadius: 6, color: AppColors.mediaBlack),
],
), ),
), ),
], ],
@@ -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';
}
+5
View File
@@ -30,6 +30,7 @@ class RecommendPage extends StatefulWidget {
this.catalogApi, this.catalogApi,
required this.active, required this.active,
required this.onSearch, required this.onSearch,
this.onOpenWallet,
this.videoOnly = false, this.videoOnly = false,
this.onImmersiveChanged, this.onImmersiveChanged,
}); });
@@ -39,6 +40,9 @@ class RecommendPage extends StatefulWidget {
final bool active; final bool active;
final VoidCallback onSearch; final VoidCallback onSearch;
/// 顶部金色礼物盒(钱包入口)。
final VoidCallback? onOpenWallet;
/// 画中画时只留视频,不画顶栏。 /// 画中画时只留视频,不画顶栏。
final bool videoOnly; final bool videoOnly;
final ValueChanged<bool>? onImmersiveChanged; final ValueChanged<bool>? onImmersiveChanged;
@@ -272,6 +276,7 @@ class _RecommendPageState extends State<RecommendPage>
onVideo: _tab == RecommendInnerTab.feed, onVideo: _tab == RecommendInnerTab.feed,
onSelected: _selectTab, onSelected: _selectTab,
onSearch: _handleSearch, 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 'package:flutter/material.dart';
import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../l10n/app_localizations.dart'; import '../../../l10n/app_localizations.dart';
import '../recommend_types.dart'; import '../recommend_types.dart';
const double _navHeight = 56; const double _navHeight = 56;
const double _tabGap = 22; const double _tabGap = 20;
const double _tabFont = 17; const double _tabFont = 17;
const double _searchHitSize = 40; const double _searchHitSize = 40;
const double _searchIconSize = 26; const double _searchIconSize = 26;
const double _minNavWidth = 280; const double _giftIconSize = 30;
const double _underlineWidth = 26;
const double _underlineHeight = 3;
class RecommendTopNav extends StatelessWidget { class RecommendTopNav extends StatelessWidget {
const RecommendTopNav({ const RecommendTopNav({
@@ -21,6 +22,7 @@ class RecommendTopNav extends StatelessWidget {
required this.onSelected, required this.onSelected,
required this.onSearch, required this.onSearch,
this.onVideo = false, this.onVideo = false,
this.onGift,
}); });
static const double height = _navHeight; static const double height = _navHeight;
@@ -30,6 +32,9 @@ class RecommendTopNav extends StatelessWidget {
final VoidCallback onSearch; final VoidCallback onSearch;
final bool onVideo; final bool onVideo;
/// 右上角金色礼物盒(钱包入口);为空时不显示。
final VoidCallback? onGift;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
@@ -41,34 +46,16 @@ class RecommendTopNav extends StatelessWidget {
final shadows = onVideo final shadows = onVideo
? const [Shadow(color: AppColors.mediaBlack, blurRadius: 6)] ? const [Shadow(color: AppColors.mediaBlack, blurRadius: 6)]
: null; : 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( return SizedBox(
key: const Key('recommend-top-nav'), key: const Key('recommend-top-nav'),
height: height, height: height,
child: Padding( child: Row(
padding: const EdgeInsets.symmetric( crossAxisAlignment: CrossAxisAlignment.center,
horizontal: AppDimens.feedPageHorizontal,
),
child: Stack(
alignment: Alignment.center,
children: [ children: [
FittedBox( Expanded(
fit: BoxFit.scaleDown, child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.only(left: 20, right: 8),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@@ -76,6 +63,7 @@ class RecommendTopNav extends StatelessWidget {
if (index > 0) const SizedBox(width: _tabGap), if (index > 0) const SizedBox(width: _tabGap),
_tab( _tab(
key: Key('recommend-tab-${items[index].$3}'), key: Key('recommend-tab-${items[index].$3}'),
code: items[index].$3,
label: items[index].$2, label: items[index].$2,
selected: selected == items[index].$1, selected: selected == items[index].$1,
shadows: shadows, shadows: shadows,
@@ -85,9 +73,8 @@ class RecommendTopNav extends StatelessWidget {
], ],
), ),
), ),
Align( ),
alignment: Alignment.centerRight, SizedBox.square(
child: SizedBox.square(
dimension: _searchHitSize, dimension: _searchHitSize,
child: IconButton( child: IconButton(
key: const Key('recommend-search'), key: const Key('recommend-search'),
@@ -103,15 +90,36 @@ class RecommendTopNav extends StatelessWidget {
icon: const Icon(Icons.search_rounded), 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({ Widget _tab({
required Key key, required Key key,
required String code,
required String label, required String label,
required bool selected, required bool selected,
required List<Shadow>? shadows, required List<Shadow>? shadows,
@@ -121,24 +129,34 @@ class RecommendTopNav extends StatelessWidget {
key: key, key: key,
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
onTap: onTap, onTap: onTap,
child: SizedBox( child: Column(
height: height, mainAxisAlignment: MainAxisAlignment.center,
child: Center( children: [
child: Text( // 补偿文字下方的 4pt 间距 + 3pt 下划线,让文字视觉中心与右侧图标对齐。
const SizedBox(height: 4 + _underlineHeight),
Text(
label, label,
maxLines: 1, maxLines: 1,
style: TextStyle( style: TextStyle(
color: selected color: AppColors.textPrimary,
? AppColors.textPrimary
: AppColors.textTabInactive,
fontSize: _tabFont, fontSize: _tabFont,
fontWeight: selected ? FontWeight.w700 : FontWeight.w400, fontWeight: selected ? FontWeight.w700 : FontWeight.w400,
letterSpacing: 0, letterSpacing: 0,
shadows: shadows, 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),
), ),
), ),
],
),
); );
} }
} }