Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5d13549f5 | ||
|
|
c027acc91f | ||
|
|
76429628ec | ||
|
|
43f3593218 | ||
|
|
0eca7ecc31 | ||
|
|
af5d8f9d80 | ||
|
|
b84a5368d6 | ||
|
|
67e178c89d |
No files matched your search
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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": {
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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,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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>[
|
||||||
|
if (isHotDrama(widget.drama))
|
||||||
|
_TagChip(
|
||||||
|
key: const Key('home-feed-hot-badge'),
|
||||||
|
label: l10n.homeBadgeHot,
|
||||||
|
filled: true,
|
||||||
|
),
|
||||||
|
for (final genre in genres)
|
||||||
|
_TagChip(key: Key('home-feed-tag-$genre'), label: genre),
|
||||||
|
];
|
||||||
|
return SizedBox(
|
||||||
key: const Key('home-feed-tags'),
|
key: const Key('home-feed-tags'),
|
||||||
children: [
|
height: 25,
|
||||||
if (isHotDrama(widget.drama)) ...[
|
child: ListView.separated(
|
||||||
Container(
|
scrollDirection: Axis.horizontal,
|
||||||
key: const Key('home-feed-hot-badge'),
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
shrinkWrap: true,
|
||||||
decoration: BoxDecoration(
|
itemCount: chips.length,
|
||||||
color: AppColors.error,
|
separatorBuilder: (_, _) => const SizedBox(width: 8),
|
||||||
borderRadius: BorderRadius.circular(3),
|
itemBuilder: (_, index) => chips[index],
|
||||||
),
|
),
|
||||||
child: Text(
|
|
||||||
l10n.homeBadgeHot,
|
|
||||||
style: const TextStyle(
|
|
||||||
color: AppColors.textPrimary,
|
|
||||||
fontSize: 10,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
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,26 +201,28 @@ 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.rich(
|
||||||
Text(synopsis, key: const Key('home-feed-synopsis'), style: style),
|
span,
|
||||||
Align(
|
key: const Key('home-feed-synopsis'),
|
||||||
alignment: Alignment.centerRight,
|
style: style,
|
||||||
child: _synopsisToggle(
|
),
|
||||||
key: const Key('home-feed-synopsis-more'),
|
const SizedBox(height: 2),
|
||||||
label: l10n.showLess,
|
Align(
|
||||||
),
|
alignment: Alignment.centerRight,
|
||||||
|
child: _synopsisToggle(
|
||||||
|
key: const Key('home-feed-synopsis-more'),
|
||||||
|
label: l10n.showLess,
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -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,
|
children: [
|
||||||
),
|
Expanded(
|
||||||
child: Stack(
|
child: SingleChildScrollView(
|
||||||
alignment: Alignment.center,
|
scrollDirection: Axis.horizontal,
|
||||||
children: [
|
padding: const EdgeInsets.only(left: 20, right: 8),
|
||||||
FittedBox(
|
|
||||||
fit: BoxFit.scaleDown,
|
|
||||||
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,33 +73,53 @@ 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'),
|
tooltip: l10n.search,
|
||||||
tooltip: l10n.search,
|
onPressed: onSearch,
|
||||||
onPressed: onSearch,
|
padding: EdgeInsets.zero,
|
||||||
padding: EdgeInsets.zero,
|
constraints: const BoxConstraints.tightFor(
|
||||||
constraints: const BoxConstraints.tightFor(
|
width: _searchHitSize,
|
||||||
width: _searchHitSize,
|
height: _searchHitSize,
|
||||||
height: _searchHitSize,
|
),
|
||||||
),
|
iconSize: _searchIconSize,
|
||||||
iconSize: _searchIconSize,
|
color: AppColors.textPrimary,
|
||||||
color: AppColors.textPrimary,
|
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,23 +129,33 @@ 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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user