diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart index b6e52fa..1bfe192 100644 --- a/lib/core/theme/app_colors.dart +++ b/lib/core/theme/app_colors.dart @@ -133,9 +133,10 @@ abstract final class AppColors { /// 底部导航底色。 static const bar = ink; - /// 底部导航专用:纯黑背景、选中白与未选中灰。 + /// 底部导航专用:纯黑背景、选中品牌红与未选中白。 static const navBackground = Color(0xFF000000); - static const navSelected = Color(0xFFFFFFFF); + static const brandRed = Color(0xFFF04E4E); + static const navSelected = brandRed; /// 卡片阴影(深色几乎不用阴影)。 static const cardShadow = Color(0x00000000); @@ -188,8 +189,8 @@ abstract final class AppColors { /// 封面集数胶囊。 static const badgeBeige = raised; - /// 导航未选中。 - static const navUnselected = textSecondary; + /// 导航未选中(设计稿为白色线框 + 白色文字)。 + static const navUnselected = Color(0xFFFFFFFF); /// 首页内容角标。 static const badgeHot = Color(0xFFE5484D); diff --git a/lib/core/widgets/app_root_tab.dart b/lib/core/widgets/app_root_tab.dart index c9f0c96..c5a9c3f 100644 --- a/lib/core/widgets/app_root_tab.dart +++ b/lib/core/widgets/app_root_tab.dart @@ -7,18 +7,12 @@ import 'app_bottom_nav.dart'; /// 客户端根导航的固定顺序。 enum AppRootTab { - home( - assetPath: 'assets/icon/film_reel.png', - selectedAssetPath: 'assets/icon/film_reel_filled.png', - ), + home(), recommend(), following(), mine(); - const AppRootTab({this.assetPath, this.selectedAssetPath}); - - final String? assetPath; - final String? selectedAssetPath; + const AppRootTab(); } extension AppRootTabUi on AppRootTab { @@ -30,15 +24,15 @@ extension AppRootTabUi on AppRootTab { }; IconData get icon => switch (this) { - AppRootTab.home => Icons.theaters_outlined, - AppRootTab.recommend => Icons.smart_display_outlined, + AppRootTab.home => Icons.home_outlined, + AppRootTab.recommend => Icons.celebration_outlined, AppRootTab.following => Icons.bookmark_border_rounded, AppRootTab.mine => Icons.person_outline_rounded, }; IconData get selectedIcon => switch (this) { - AppRootTab.home => Icons.theaters, - AppRootTab.recommend => Icons.smart_display_rounded, + AppRootTab.home => Icons.home_rounded, + AppRootTab.recommend => Icons.celebration_rounded, AppRootTab.following => Icons.bookmark_rounded, AppRootTab.mine => Icons.person_rounded, }; @@ -75,8 +69,6 @@ class AppRootBottomNav extends StatelessWidget { tabKey: tab.tabKey, icon: tab.icon, selectedIcon: tab.selectedIcon, - assetPath: tab.assetPath, - selectedAssetPath: tab.selectedAssetPath, label: tab.label(l10n), selected: tab == selected, onTap: () => onSelected(tab), diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index bb5f156..0536a3e 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -55,12 +55,24 @@ "viewers": {"type": "String"} } }, - "watchFullSeries": "Watch full series · {episodes} eps", + "watchFullSeries": "Watch full series {episodes} eps", "@watchFullSeries": { "placeholders": { "episodes": {"type": "String"} } }, + "feedEpisodePrefix": "EP{episode} | ", + "@feedEpisodePrefix": { + "placeholders": { + "episode": {"type": "int"} + } + }, + "feedWatchingCount": "{count} watching", + "@feedWatchingCount": { + "placeholders": { + "count": {"type": "String"} + } + }, "feedTabAnime": "Anime", "feedTabReal": "Live-action", "feedTabForYou": "For you", @@ -265,6 +277,18 @@ "pictureInPicture": "Picture in picture", "pipUnavailable": "Picture in picture is unavailable on this device", "originalTag": "Original", + "originalProduction": "Original", + "watchNowAction": "Watch Now", + "newArrivalBadge": "NEW", + "editorPick": "Editor's Pick", + "totalEpisodesShort": "{count} eps", + "@totalEpisodesShort": { + "placeholders": { + "count": { + "type": "int" + } + } + }, "dramaTag": "Short drama", "episodeLabel": "Episode {number}", "@episodeLabel": { diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 3a2cf44..c6be6ec 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -49,7 +49,24 @@ "hotBadge": "Hits", "filterAll": "Semua", "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", "feedTabReal": "Live-action", "feedTabForYou": "Untukmu", @@ -239,6 +256,18 @@ "pictureInPicture": "Gambar dalam gambar", "pipUnavailable": "Gambar dalam gambar tidak tersedia di perangkat ini", "originalTag": "Original", + "originalProduction": "Original", + "watchNowAction": "Tonton Sekarang", + "newArrivalBadge": "BARU", + "editorPick": "Pilihan Editor", + "totalEpisodesShort": "{count} eps", + "@totalEpisodesShort": { + "placeholders": { + "count": { + "type": "int" + } + } + }, "dramaTag": "Drama pendek", "episodeLabel": "Episode {number}", "@episodeLabel": { diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index f9b1055..0975ce6 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -397,9 +397,21 @@ abstract class AppLocalizations { /// No description provided for @watchFullSeries. /// /// In en, this message translates to: - /// **'Watch full series · {episodes} eps'** + /// **'Watch full series {episodes} eps'** 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. /// /// In en, this message translates to: @@ -1198,6 +1210,36 @@ abstract class AppLocalizations { /// **'Original'** 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. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 82b3a5d..2ba335d 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -162,7 +162,17 @@ class AppLocalizationsEn extends AppLocalizations { @override 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 @@ -594,6 +604,23 @@ class AppLocalizationsEn extends AppLocalizations { @override 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 String get dramaTag => 'Short drama'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 18bef15..4e9d1dd 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -162,7 +162,17 @@ class AppLocalizationsId extends AppLocalizations { @override 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 @@ -595,6 +605,23 @@ class AppLocalizationsId extends AppLocalizations { @override 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 String get dramaTag => 'Drama pendek'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 1acfb79..dbf6af9 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -159,7 +159,17 @@ class AppLocalizationsZh extends AppLocalizations { @override String watchFullSeries(String episodes) { - return '观看完整剧集 · 全$episodes集'; + return '观看完整剧集 全$episodes集'; + } + + @override + String feedEpisodePrefix(int episode) { + return '第$episode集|'; + } + + @override + String feedWatchingCount(String count) { + return '共$count人在看'; } @override @@ -587,6 +597,23 @@ class AppLocalizationsZh extends AppLocalizations { @override 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 String get dramaTag => '短剧'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index f6a4262..b94416d 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -49,7 +49,24 @@ "hotBadge": "爆剧", "filterAll": "全部", "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": "漫剧", "feedTabReal": "真人剧", "feedTabForYou": "推荐", @@ -239,6 +256,18 @@ "pictureInPicture": "画中画", "pipUnavailable": "此设备无法使用画中画", "originalTag": "原创", + "originalProduction": "原创出品", + "watchNowAction": "立即观看", + "newArrivalBadge": "上新", + "editorPick": "编辑精选", + "totalEpisodesShort": "全{count}集", + "@totalEpisodesShort": { + "placeholders": { + "count": { + "type": "int" + } + } + }, "dramaTag": "短剧", "episodeLabel": "第 {number} 集", "@episodeLabel": { diff --git a/lib/pages/home/home_view.dart b/lib/pages/home/home_view.dart index 24c26c6..66e2ce3 100644 --- a/lib/pages/home/home_view.dart +++ b/lib/pages/home/home_view.dart @@ -12,7 +12,6 @@ import '../../core/data/services/home_feed_controller.dart'; import '../../core/data/services/catalog_controller.dart'; import '../../core/data/services/category_controller.dart'; import '../../core/data/services/favorite_controller.dart'; -import '../../core/data/services/wallet_controller.dart'; import '../../core/platform/haptics.dart'; import '../../core/theme/app_colors.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 '../discover/search_page.dart'; import '../following/following_page.dart'; -import '../filter/filter_page.dart'; import '../mine/mine_types.dart'; import '../recommend/recommend_page.dart'; +import '../recommend/recommend_discover_page.dart'; import '../mine/mine_view.dart'; -import '../store/vip_page.dart'; import '../wallet/wallet_page.dart'; import 'home_binding.dart'; import 'home_logic.dart'; -import 'widgets/home_feed_grid.dart'; -import 'widgets/home_grid_header.dart'; class HomePage extends StatefulWidget { const HomePage({super.key}); @@ -176,8 +172,8 @@ class _HomeTabsState extends State<_HomeTabs> { Widget _body(BuildContext context) { final pages = [ - _homeGrid(context), _recommend(context), + _recommendDiscover(context), FollowingPage( onGoHome: () => logic.select(0), favoriteController: context.read(), @@ -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) { return RecommendPage( feedController: widget.feedController, catalogApi: _catalogApi(context), - active: logic.tab == 1, + active: logic.tab == 0, videoOnly: logic.videoImmersive, onImmersiveChanged: logic.onImmersiveChanged, 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(MaterialPageRoute(builder: (_) => const SearchPage())); } - void _pushPage(BuildContext context, Widget page) { - Navigator.of(context).push(MaterialPageRoute(builder: (_) => page)); - } - - Future _openVip(BuildContext context) async { - if (!await ensureSignedIn(context) || !context.mounted) return; - final wallet = context.read(); - await Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => VipPage(onPurchased: () => unawaited(wallet.load())), - ), - ); - } - Future _openWallet(BuildContext context) async { if (!await ensureSignedIn(context) || !context.mounted) return; await Navigator.of( diff --git a/lib/pages/home/widgets/home_feed_card.dart b/lib/pages/home/widgets/home_feed_card.dart index fa002d9..e8aeaa5 100644 --- a/lib/pages/home/widgets/home_feed_card.dart +++ b/lib/pages/home/widgets/home_feed_card.dart @@ -50,7 +50,7 @@ class HomeFeedCard extends StatelessWidget { overflow: TextOverflow.ellipsis, textAlign: TextAlign.left, style: const TextStyle( - color: AppColors.navSelected, + color: AppColors.textPrimary, fontSize: AppDimens.cardTitleFont, fontWeight: FontWeight.w500, height: AppDimens.cardTitleLineHeight, diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 357008f..6aede6d 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -1200,6 +1200,7 @@ class _PlayerPageState extends State unawaited(_presentFullPlayer(context, index)); }, onEpisodes: () => logic.showEpisodes(context), + onOpenSearch: widget.onSearch, onMore: _openCurrentMore, onScrubStart: videoReady ? () => logic.beginScrub(videoPort!) diff --git a/lib/pages/player/widgets/home_feed_overlay.dart b/lib/pages/player/widgets/home_feed_overlay.dart index dc10f8b..b281f23 100644 --- a/lib/pages/player/widgets/home_feed_overlay.dart +++ b/lib/pages/player/widgets/home_feed_overlay.dart @@ -22,11 +22,13 @@ part 'home_feed_overlay_parts.dart'; const double _progressHeight = 2; const double _progressHitHeight = 32; -const double _pageHorizontal = 14; -const double _railRight = 8; +const double _pageHorizontal = 20; +const double _railRight = 14; const double _railGap = 12; -const double _bottomInfoInset = 32; -const double _railBottomOffset = 60; +// 观看条高 44;2pt 进度线视觉上沿距观看条顶约 7pt(线中心距进度条盒底 8)。 +// 信息面板底边取 44+16=60,使简介↔进度线、进度线↔观看条两侧间距均为 7pt。 +const double _bottomInfoInset = homeFeedWatchAllHeight + 16; +const double _railBottomOffset = 40; const Duration _overlayFadeDuration = Duration(milliseconds: 120); const List _boostTextShadows = [ Shadow(color: AppColors.shadow, blurRadius: 5, offset: Offset(0, 1)), @@ -52,6 +54,7 @@ class HomeFeedOverlay extends StatefulWidget { required this.onDetail, required this.onOpenFullPlayer, this.onEpisodes, + this.onOpenSearch, required this.onTogglePlay, required this.onBoostStart, required this.onBoostEnd, @@ -92,6 +95,9 @@ class HomeFeedOverlay extends StatefulWidget { final VoidCallback onDetail; final VoidCallback onOpenFullPlayer; final VoidCallback? onEpisodes; + + /// 底部热搜胶囊点击(降级版:跳搜索页)。 + final VoidCallback? onOpenSearch; final VoidCallback onTogglePlay; final VoidCallback onBoostStart; final VoidCallback onBoostEnd; @@ -173,7 +179,7 @@ class _HomeFeedOverlayState extends State { Positioned( left: 0, right: 0, - bottom: 20, + bottom: homeFeedWatchAllHeight + 12, child: AnimatedOpacity( duration: _overlayFadeDuration, opacity: widget.boosting ? 1 : 0, @@ -184,9 +190,9 @@ class _HomeFeedOverlayState extends State { ), ), Positioned( - left: 0, - right: 0, - bottom: 0, + left: _pageHorizontal, + right: _pageHorizontal, + bottom: homeFeedWatchAllHeight, child: _FeedProgressBar( controller: widget.controller, locked: widget.locked, @@ -203,6 +209,24 @@ class _HomeFeedOverlayState extends State { 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 { onSave: widget.onSave, onLike: widget.onLike, onComments: widget.onComments, - onEpisodes: widget.onEpisodes ?? widget.onOpenFullPlayer, + onShare: widget.onShare, ); } @@ -283,6 +307,7 @@ class _HomeFeedOverlayState extends State { episode: widget.episode, onDetail: widget.onDetail, onOpenFullPlayer: widget.onOpenFullPlayer, + onOpenSearch: widget.onOpenSearch, ); } } diff --git a/lib/pages/player/widgets/home_feed_overlay_parts.dart b/lib/pages/player/widgets/home_feed_overlay_parts.dart index 3f534cc..d938d2d 100644 --- a/lib/pages/player/widgets/home_feed_overlay_parts.dart +++ b/lib/pages/player/widgets/home_feed_overlay_parts.dart @@ -6,7 +6,6 @@ const double _thumbSize = 6; const double _thumbActiveSize = 14; const double _trackActiveHeight = 6; const double _lineCenterFromBottom = 8; -const Duration _trackResizeDuration = Duration(milliseconds: 120); const double _timeBubbleWidth = 96; const double _timeBubbleMargin = 12; const Key _inactiveTrackKey = Key('home-feed-progress-inactive'); @@ -371,10 +370,8 @@ class _FeedProgressBarState extends State<_FeedProgressBar> { children: [ Align( alignment: Alignment.centerLeft, - child: AnimatedContainer( + child: Container( key: const Key('home-feed-progress-track'), - duration: _trackResizeDuration, - curve: Curves.easeOut, width: width, height: trackHeight, color: AppColors.border, @@ -382,10 +379,8 @@ class _FeedProgressBarState extends State<_FeedProgressBar> { ), Align( alignment: Alignment.centerLeft, - child: AnimatedContainer( + child: Container( key: const Key('home-feed-progress-played'), - duration: dragging ? Duration.zero : _trackResizeDuration, - curve: Curves.easeOut, width: playedWidth, height: trackHeight, color: AppColors.blue, @@ -401,10 +396,8 @@ class _FeedProgressBarState extends State<_FeedProgressBar> { child: SizedBox.square( dimension: _thumbActiveSize, child: Center( - child: AnimatedContainer( + child: Container( key: const Key('home-feed-progress-thumb'), - duration: _trackResizeDuration, - curve: Curves.easeOut, width: size, height: size, decoration: const BoxDecoration( diff --git a/lib/pages/player/widgets/home_feed_recommend_panel.dart b/lib/pages/player/widgets/home_feed_recommend_panel.dart index 56c049f..7352b31 100644 --- a/lib/pages/player/widgets/home_feed_recommend_panel.dart +++ b/lib/pages/player/widgets/home_feed_recommend_panel.dart @@ -1,8 +1,7 @@ // Author: fruit.com.cn -import 'dart:math' as math; - import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; import '../../../core/data/models/drama.dart'; import '../../../core/formatting/localized_content.dart'; @@ -11,15 +10,22 @@ import '../../../l10n/app_localizations.dart'; import '../../home/widgets/home_drama_classification.dart'; import 'interaction_rail.dart'; -const double _seriesFont = 19; -const double _tagFont = 13; -const double _synopsisFont = 13; +const double _seriesFont = 21; +const double _tagFont = 14; +const double _synopsisFont = 15; const double _watchAllHeight = 44; const List _textShadows = [ Shadow(color: AppColors.shadow, blurRadius: 6, offset: Offset(0, 1)), ]; 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 { const HomeFeedInfoPanel({ super.key, @@ -27,6 +33,7 @@ class HomeFeedInfoPanel extends StatefulWidget { required this.episode, required this.onDetail, required this.onOpenFullPlayer, + this.onOpenSearch, }); final Drama drama; @@ -34,6 +41,9 @@ class HomeFeedInfoPanel extends StatefulWidget { final VoidCallback onDetail; final VoidCallback onOpenFullPlayer; + /// 热搜胶囊点击;为空或播放量为 0 时不显示胶囊。 + final VoidCallback? onOpenSearch; + @override State createState() => _HomeFeedInfoPanelState(); } @@ -48,9 +58,22 @@ class _HomeFeedInfoPanelState extends State { final synopsis = localizedDramaSynopsis(l10n, widget.drama).trim(); final genres = _genreValues(Localizations.localeOf(context).languageCode); return Column( + key: const Key('home-feed-info-panel'), mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, 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( key: const Key('home-feed-series-name'), behavior: HitTestBehavior.opaque, @@ -67,12 +90,12 @@ class _HomeFeedInfoPanelState extends State { style: const TextStyle( color: AppColors.textPrimary, fontSize: _seriesFont, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, shadows: _textShadows, ), ), ), - const SizedBox(width: 4), + const SizedBox(width: 2), const Icon( Icons.chevron_right_rounded, size: 20, @@ -81,119 +104,96 @@ class _HomeFeedInfoPanelState extends State { ], ), ), - const SizedBox(height: 8), + const SizedBox(height: 6), _tagMetaRow(l10n, genres), if (synopsis.isNotEmpty) ...[ const SizedBox(height: 8), _synopsis(l10n, synopsis), ], - const SizedBox(height: 12), - _watchAll(context, l10n), ], ); } Widget _tagMetaRow(AppLocalizations l10n, List genres) { - return Row( + final chips = [ + 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'), - children: [ - if (isHotDrama(widget.drama)) ...[ - Container( - key: const Key('home-feed-hot-badge'), - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: AppColors.error, - borderRadius: BorderRadius.circular(3), - ), - 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, - ), - ), - ), - ], + height: 25, + child: ListView.separated( + scrollDirection: Axis.horizontal, + physics: const NeverScrollableScrollPhysics(), + shrinkWrap: true, + itemCount: chips.length, + separatorBuilder: (_, _) => const SizedBox(width: 8), + itemBuilder: (_, index) => chips[index], + ), ); } Widget _synopsis(AppLocalizations l10n, String synopsis) { - const style = TextStyle( - color: AppColors.overlaySecondary, + final prefix = l10n.feedEpisodePrefix(widget.episode.number); + final baseStyle = const TextStyle( + color: AppColors.textPrimary, fontSize: _synopsisFont, - height: 1.35, + height: 1.3, 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( builder: (context, constraints) { + final toggle = _synopsisToggle( + key: const Key('home-feed-synopsis-more'), + label: l10n.expandSynopsis, + ); final painter = TextPainter( - text: TextSpan(text: synopsis, style: style), - maxLines: 2, + text: span, + maxLines: 1, textDirection: Directionality.of(context), textScaler: MediaQuery.textScalerOf(context), )..layout(maxWidth: constraints.maxWidth); - final overflows = painter.didExceedMaxLines; - if (!overflows) { - return Text( - synopsis, + if (!painter.didExceedMaxLines) { + return Text.rich( + span, key: const Key('home-feed-synopsis'), - style: style, + maxLines: 1, + style: baseStyle, ); } - if (_synopsisExpanded) { - return _expandedSynopsis(constraints.maxWidth, synopsis, style, l10n); - } - return Stack( + // 设计稿:单行省略号 + 行内固定「展开」。 + return Row( + key: const Key('home-feed-synopsis-collapsed'), children: [ - Padding( - padding: const EdgeInsets.only(right: 40), - child: Text( - synopsis, + Expanded( + child: Text.rich( + span, key: const Key('home-feed-synopsis'), - maxLines: 2, + maxLines: 1, overflow: TextOverflow.ellipsis, - style: style, - ), - ), - Positioned( - right: 0, - bottom: 0, - child: _synopsisToggle( - key: const Key('home-feed-synopsis-more'), - label: l10n.expandSynopsis, + style: baseStyle, ), ), + const SizedBox(width: 2), + toggle, ], ); }, @@ -201,26 +201,28 @@ class _HomeFeedInfoPanelState extends State { } Widget _expandedSynopsis( - double maxWidth, - String synopsis, + InlineSpan span, TextStyle style, AppLocalizations l10n, ) { - return SizedBox( - width: maxWidth, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text(synopsis, key: const Key('home-feed-synopsis'), style: style), - Align( - alignment: Alignment.centerRight, - child: _synopsisToggle( - key: const Key('home-feed-synopsis-more'), - label: l10n.showLess, - ), + return Column( + key: const Key('home-feed-synopsis-expanded'), + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text.rich( + span, + key: const Key('home-feed-synopsis'), + style: style, + ), + const SizedBox(height: 2), + Align( + alignment: Alignment.centerRight, + child: _synopsisToggle( + key: const Key('home-feed-synopsis-more'), + label: l10n.showLess, ), - ], - ), + ), + ], ); } @@ -234,54 +236,12 @@ class _HomeFeedInfoPanelState extends State { color: AppColors.textPrimary, fontSize: _synopsisFont, 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 _genreValues(String languageCode) { final localized = widget.drama.localizedTypeLabels(languageCode); if (localized.isNotEmpty) return localized.take(3).toList(growable: false); @@ -297,6 +257,147 @@ class _HomeFeedInfoPanelState extends State { } } +/// 热搜榜降级胶囊:🔍 共 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 { const HomeFeedActionRail({ super.key, @@ -305,7 +406,7 @@ class HomeFeedActionRail extends StatelessWidget { required this.onSave, required this.onLike, required this.onComments, - required this.onEpisodes, + required this.onShare, this.favoriteCount, this.likeCount, this.commentCount, @@ -319,13 +420,17 @@ class HomeFeedActionRail extends StatelessWidget { final VoidCallback onSave; final VoidCallback onLike; final VoidCallback onComments; - final VoidCallback onEpisodes; + final VoidCallback onShare; @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; return InteractionRail( - itemGap: 14, + itemGap: 18, + buttonSize: 48, + iconSize: 36, + countFontSize: 15, + iconCountGap: 6, items: [ InteractionRailItem( key: const Key('rail-save'), @@ -334,18 +439,9 @@ class HomeFeedActionRail extends StatelessWidget { count: favoriteCount, countKey: const Key('rail-save-count'), selected: saved, + selectedColor: AppColors.brandRed, 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( key: const Key('rail-comments'), asset: 'assets/icon/rail/rail_comment.png', @@ -355,11 +451,22 @@ class HomeFeedActionRail extends StatelessWidget { onTap: onComments, ), InteractionRailItem( - key: const Key('rail-episodes'), - icon: Icons.layers_rounded, - label: l10n.episodes, - countKey: const Key('rail-episodes-label'), - onTap: onEpisodes, + 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.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, ), ], ); diff --git a/lib/pages/player/widgets/interaction_rail.dart b/lib/pages/player/widgets/interaction_rail.dart index 38f1369..1b3f3ba 100644 --- a/lib/pages/player/widgets/interaction_rail.dart +++ b/lib/pages/player/widgets/interaction_rail.dart @@ -5,8 +5,10 @@ import 'package:flutter/material.dart'; import '../../../core/formatting/count_format.dart'; import '../../../core/theme/app_colors.dart'; -const double _buttonSize = 44; -const double _iconSize = 32; +const double _defaultButtonSize = 44; +const double _defaultIconSize = 32; +const double _defaultCountFont = 12; +const double _defaultIconCountGap = 2; const double _defaultItemGap = 24; /// 互动栏单项:图标、计数与点击。 @@ -21,6 +23,7 @@ class InteractionRailItem { this.countKey, this.selected = false, this.selectedColor = AppColors.blue, + this.mirrorIcon = false, }) : assert((asset == null) != (icon == null)); final Key key; @@ -32,6 +35,9 @@ class InteractionRailItem { final Key? countKey; final bool selected; final Color selectedColor; + + /// 水平镜像图标(分享弯箭头用 reply 镜像得到)。 + final bool mirrorIcon; } /// 播放页与首页 feed 共用的右侧互动栏:收藏/评论/喜欢/分享。 @@ -40,10 +46,18 @@ class InteractionRail extends StatelessWidget { super.key, required this.items, this.itemGap = _defaultItemGap, + this.buttonSize = _defaultButtonSize, + this.iconSize = _defaultIconSize, + this.countFontSize = _defaultCountFont, + this.iconCountGap = _defaultIconCountGap, }); final List items; final double itemGap; + final double buttonSize; + final double iconSize; + final double countFontSize; + final double iconCountGap; @override Widget build(BuildContext context) { @@ -52,7 +66,13 @@ class InteractionRail extends StatelessWidget { children: [ for (var index = 0; index < items.length; index++) ...[ 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 { - 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 double buttonSize; + final double iconSize; + final double countFontSize; + final double iconCountGap; @override Widget build(BuildContext context) { + final tint = item.selected ? item.selectedColor : AppColors.onPrimary; return Column( key: item.key, mainAxisSize: MainAxisSize.min, children: [ SizedBox.square( - dimension: _buttonSize, + dimension: buttonSize, child: IconButton( onPressed: item.onTap, padding: EdgeInsets.zero, icon: item.asset != null ? Image.asset( item.asset!, - width: _iconSize, - height: _iconSize, + width: iconSize, + height: iconSize, fit: BoxFit.contain, - color: item.selected - ? item.selectedColor - : AppColors.onPrimary, + color: tint, colorBlendMode: BlendMode.srcIn, ) - : Icon( - item.icon, - size: _iconSize, - color: item.selected - ? item.selectedColor - : AppColors.onPrimary, + : Transform.flip( + flipX: item.mirrorIcon, + child: Icon(item.icon, size: iconSize, color: tint), ), ), ), - const SizedBox(height: 2), + SizedBox(height: iconCountGap), Text( item.count == null ? item.label : formatInteractionCount(item.count!), key: item.countKey, maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle( + style: TextStyle( color: AppColors.onPrimary, height: 1, - fontSize: 12, + fontSize: countFontSize, fontWeight: FontWeight.w600, - shadows: [Shadow(blurRadius: 6, color: AppColors.mediaBlack)], + shadows: const [ + Shadow(blurRadius: 6, color: AppColors.mediaBlack), + ], ), ), ], diff --git a/lib/pages/recommend/recommend_discover_page.dart b/lib/pages/recommend/recommend_discover_page.dart new file mode 100644 index 0000000..e9ebe34 --- /dev/null +++ b/lib/pages/recommend/recommend_discover_page.dart @@ -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 createState() => _RecommendDiscoverPageState(); +} + +class _RecommendDiscoverPageState extends State + with AutomaticKeepAliveClientMixin { + /// 当前频道 id;频道全部来自分类接口,首项即「精选」。 + String? _channelId; + final Set _requestedCategories = {}; + final PageController _pageController = PageController(); + + /// `GET /app/home/feed` 返回的轮播图,只挂在第一个「精选」频道。 + List _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 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(); + 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 _refreshCategory(String id, bool isFeatured) async { + final categories = context.read(); + final tasks = >[categories.openCategory(id)]; + if (isFeatured) tasks.add(_loadBanners(force: true)); + await Future.wait(tasks); + } + + /// 轮播图只依赖 home/feed 的 banners;接口缺失(如测试注入)时静默跳过, + /// 不影响下方分类网格。 + Future _loadBanners({bool force = false}) async { + if (_bannersRequested && !force) return; + _bannersRequested = true; + CatalogApi? api; + try { + api = context.read(); + } 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 _openDrama(Drama drama) => + playDramaFromCatalog(context, drama); + + @override + Widget build(BuildContext context) { + super.build(context); + final categories = context.watch(); + 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( + '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 banners; + final bool carouselActive; + final double mediaTop; + final ValueChanged onRequest; + final Future Function() onRefresh; + final ValueChanged 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 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(); + 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 newArrivals = const []; + List 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 = []; + 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( + 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('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 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'; +} diff --git a/lib/pages/recommend/recommend_page.dart b/lib/pages/recommend/recommend_page.dart index 8508219..93c98ec 100644 --- a/lib/pages/recommend/recommend_page.dart +++ b/lib/pages/recommend/recommend_page.dart @@ -30,6 +30,7 @@ class RecommendPage extends StatefulWidget { this.catalogApi, required this.active, required this.onSearch, + this.onOpenWallet, this.videoOnly = false, this.onImmersiveChanged, }); @@ -39,6 +40,9 @@ class RecommendPage extends StatefulWidget { final bool active; final VoidCallback onSearch; + /// 顶部金色礼物盒(钱包入口)。 + final VoidCallback? onOpenWallet; + /// 画中画时只留视频,不画顶栏。 final bool videoOnly; final ValueChanged? onImmersiveChanged; @@ -272,6 +276,7 @@ class _RecommendPageState extends State onVideo: _tab == RecommendInnerTab.feed, onSelected: _selectTab, onSearch: _handleSearch, + onGift: widget.onOpenWallet, ), ), ), diff --git a/lib/pages/recommend/widgets/home_hero_carousel.dart b/lib/pages/recommend/widgets/home_hero_carousel.dart new file mode 100644 index 0000000..a8bfb6e --- /dev/null +++ b/lib/pages/recommend/widgets/home_hero_carousel.dart @@ -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 banners; + + /// 按 banner 关联到的剧目;为空时只展示头图。 + final Drama? Function(FeedBanner banner) dramaFor; + + /// 点击「立即观看」或直接点按头图。 + final ValueChanged onWatch; + + final bool active; + + /// 固定轮播高度;后台按该比例(屏宽 : height)出图即可铺满不裁切。 + static const double height = 420; + + @override + State createState() => _HomeHeroCarouselState(); +} + +class _HomeHeroCarouselState extends State { + 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(); + 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, + ), + ), + ), + ); + } +} diff --git a/lib/pages/recommend/widgets/home_recommend_content.dart b/lib/pages/recommend/widgets/home_recommend_content.dart new file mode 100644 index 0000000..d7c4b2f --- /dev/null +++ b/lib/pages/recommend/widgets/home_recommend_content.dart @@ -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 dramas; + final ValueChanged 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 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, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/pages/recommend/widgets/recommend_channel_nav.dart b/lib/pages/recommend/widgets/recommend_channel_nav.dart new file mode 100644 index 0000000..74bbdcc --- /dev/null +++ b/lib/pages/recommend/widgets/recommend_channel_nav.dart @@ -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 categories; + final String? selectedId; + final ValueChanged onSelected; + final VoidCallback onSearch; + + /// 右上角金色礼物盒(钱包入口);为空时不显示。 + final VoidCallback? onGift; + + @override + State createState() => _RecommendChannelNavState(); +} + +class _RecommendChannelNavState extends State { + static const double _revealLeftInset = 20; + + final ScrollController _scrollController = ScrollController(); + final GlobalKey _scrollViewKey = GlobalKey(); + final Map _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), + ), + ), + ], + ), + ); + } +} diff --git a/lib/pages/recommend/widgets/recommend_top_nav.dart b/lib/pages/recommend/widgets/recommend_top_nav.dart index 907bcca..1a85118 100644 --- a/lib/pages/recommend/widgets/recommend_top_nav.dart +++ b/lib/pages/recommend/widgets/recommend_top_nav.dart @@ -3,16 +3,17 @@ import 'package:flutter/material.dart'; import '../../../core/theme/app_colors.dart'; -import '../../../core/theme/app_dimens.dart'; import '../../../l10n/app_localizations.dart'; import '../recommend_types.dart'; const double _navHeight = 56; -const double _tabGap = 22; +const double _tabGap = 20; const double _tabFont = 17; const double _searchHitSize = 40; const double _searchIconSize = 26; -const double _minNavWidth = 280; +const double _giftIconSize = 30; +const double _underlineWidth = 26; +const double _underlineHeight = 3; class RecommendTopNav extends StatelessWidget { const RecommendTopNav({ @@ -21,6 +22,7 @@ class RecommendTopNav extends StatelessWidget { required this.onSelected, required this.onSearch, this.onVideo = false, + this.onGift, }); static const double height = _navHeight; @@ -30,6 +32,9 @@ class RecommendTopNav extends StatelessWidget { final VoidCallback onSearch; final bool onVideo; + /// 右上角金色礼物盒(钱包入口);为空时不显示。 + final VoidCallback? onGift; + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; @@ -41,34 +46,16 @@ class RecommendTopNav extends StatelessWidget { final shadows = onVideo ? const [Shadow(color: AppColors.mediaBlack, blurRadius: 6)] : null; - return LayoutBuilder( - builder: (context, constraints) { - if (constraints.maxWidth < _minNavWidth) { - return const SizedBox(key: Key('recommend-top-nav'), height: height); - } - return _bar(context, items, shadows); - }, - ); - } - - Widget _bar( - BuildContext context, - List<(RecommendInnerTab, String, String)> items, - List? shadows, - ) { - final l10n = AppLocalizations.of(context)!; return SizedBox( key: const Key('recommend-top-nav'), height: height, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: AppDimens.feedPageHorizontal, - ), - child: Stack( - alignment: Alignment.center, - children: [ - FittedBox( - fit: BoxFit.scaleDown, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.only(left: 20, right: 8), child: Row( mainAxisSize: MainAxisSize.min, children: [ @@ -76,6 +63,7 @@ class RecommendTopNav extends StatelessWidget { if (index > 0) const SizedBox(width: _tabGap), _tab( key: Key('recommend-tab-${items[index].$3}'), + code: items[index].$3, label: items[index].$2, selected: selected == items[index].$1, shadows: shadows, @@ -85,33 +73,53 @@ class RecommendTopNav extends StatelessWidget { ], ), ), - Align( - alignment: Alignment.centerRight, - child: SizedBox.square( - dimension: _searchHitSize, - child: IconButton( - key: const Key('recommend-search'), - tooltip: l10n.search, - onPressed: onSearch, - padding: EdgeInsets.zero, - constraints: const BoxConstraints.tightFor( - width: _searchHitSize, - height: _searchHitSize, - ), - iconSize: _searchIconSize, - color: AppColors.textPrimary, - icon: const Icon(Icons.search_rounded), + ), + SizedBox.square( + dimension: _searchHitSize, + child: IconButton( + key: const Key('recommend-search'), + tooltip: l10n.search, + onPressed: onSearch, + padding: EdgeInsets.zero, + constraints: const BoxConstraints.tightFor( + width: _searchHitSize, + height: _searchHitSize, + ), + iconSize: _searchIconSize, + color: AppColors.textPrimary, + icon: const Icon(Icons.search_rounded), + ), + ), + if (onGift != null) + SizedBox.square( + dimension: _searchHitSize, + child: IconButton( + key: const Key('recommend-gift'), + tooltip: l10n.wallet, + onPressed: onGift, + padding: EdgeInsets.zero, + constraints: const BoxConstraints.tightFor( + width: _searchHitSize, + height: _searchHitSize, + ), + iconSize: _giftIconSize, + icon: Image.asset( + 'assets/icon/gift.png', + width: _giftIconSize, + height: _giftIconSize, + fit: BoxFit.contain, ), ), ), - ], - ), + const SizedBox(width: 8), + ], ), ); } Widget _tab({ required Key key, + required String code, required String label, required bool selected, required List? shadows, @@ -121,23 +129,33 @@ class RecommendTopNav extends StatelessWidget { key: key, behavior: HitTestBehavior.opaque, onTap: onTap, - child: SizedBox( - height: height, - child: Center( - child: Text( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // 补偿文字下方的 4pt 间距 + 3pt 下划线,让文字视觉中心与右侧图标对齐。 + const SizedBox(height: 4 + _underlineHeight), + Text( label, maxLines: 1, style: TextStyle( - color: selected - ? AppColors.textPrimary - : AppColors.textTabInactive, + color: AppColors.textPrimary, fontSize: _tabFont, fontWeight: selected ? FontWeight.w700 : FontWeight.w400, letterSpacing: 0, shadows: shadows, ), ), - ), + const SizedBox(height: 4), + Container( + key: Key('recommend-tab-$code-underline'), + width: _underlineWidth, + height: _underlineHeight, + decoration: BoxDecoration( + color: selected ? AppColors.brandRed : AppColors.transparent, + borderRadius: BorderRadius.circular(_underlineHeight / 2), + ), + ), + ], ), ); }