diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index a7d3961..5d7d531 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1168,3 +1168,10 @@ $ flutter test - 公共 token 调整为图标 30、文字 13、图标文字间距 5、内容高度 62。 - 图片首页图标同步使用 30×30;SafeArea 仍在内容区外独立追加,四等分布局不变。 - 尺寸测试断言同步,按当前流程未执行 analyze/test。 + +## 2026-10-22 推荐页沉浸式布局对齐 + +- 推荐/剧单/全部短剧顶部改为视频内居中浮层,17 字号、无下划线,搜索固定右侧 14。 +- 右侧保留收藏/点赞/选集,白色 32 图标、28 间距;评论、分享入口已从该浮层移除。 +- 底部信息重构为标题、真实热度、剧名、题材/集数、可展开简介与渐变观看全集按钮。 +- 新增三语“观看全集”文案;按当前流程未执行 analyze/test。 diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart index 861b272..26e9aac 100644 --- a/lib/core/theme/app_colors.dart +++ b/lib/core/theme/app_colors.dart @@ -23,10 +23,12 @@ abstract final class AppColors { static const overlayBar = Color(0x4D000000); static const overlayMedium = Color(0x99000000); static const headerScrim = Color(0x59000000); + static const overlaySecondary = Color(0xFFC7C7CC); /// 主操作、选中态、播放进度(品牌橙)。 static const blue = Color(0xFFFF6B2C); static const primary = blue; + static const actionRed = Color(0xFFFF3B30); /// 主操作按下态。 static const bluePressed = Color(0xFFE55A1F); diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index a63715b..f5b44c3 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -748,5 +748,6 @@ }, "watchedToEpisode": "Watched to Episode {episode}", "@watchedToEpisode": { "placeholders": { "episode": { "type": "int" } } }, + "homeWatchAll": "Watch all", "confirmRemove": "Remove this item?" } diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 4250d91..025c1e8 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -705,5 +705,6 @@ }, "watchedToEpisode": "Ditonton hingga Episode {episode}", "@watchedToEpisode": { "placeholders": { "episode": { "type": "int" } } }, + "homeWatchAll": "Tonton semua", "confirmRemove": "Hapus item ini?" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 39502e6..2b3ae8a 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -3256,6 +3256,12 @@ abstract class AppLocalizations { /// **'Watched to Episode {episode}'** String watchedToEpisode(int episode); + /// No description provided for @homeWatchAll. + /// + /// In en, this message translates to: + /// **'Watch all'** + String get homeWatchAll; + /// No description provided for @confirmRemove. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 458b70a..ef3ebad 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1699,6 +1699,9 @@ class AppLocalizationsEn extends AppLocalizations { return 'Watched to Episode $episode'; } + @override + String get homeWatchAll => 'Watch all'; + @override String get confirmRemove => 'Remove this item?'; } diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 1a7e93d..f76ec32 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -1701,6 +1701,9 @@ class AppLocalizationsId extends AppLocalizations { return 'Ditonton hingga Episode $episode'; } + @override + String get homeWatchAll => 'Tonton semua'; + @override String get confirmRemove => 'Hapus item ini?'; } diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 96d0ee5..b99bb6e 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -1675,6 +1675,9 @@ class AppLocalizationsZh extends AppLocalizations { return '已看至 第 $episode 集'; } + @override + String get homeWatchAll => '观看全集'; + @override String get confirmRemove => '确定移除这条内容吗?'; } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 7f5c954..e98372e 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -667,5 +667,6 @@ }, "watchedToEpisode": "已看至 第 {episode} 集", "@watchedToEpisode": { "placeholders": { "episode": { "type": "int" } } }, + "homeWatchAll": "观看全集", "confirmRemove": "确定移除这条内容吗?" } diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index cebd854..2197109 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -621,6 +621,7 @@ class _PlayerPageState extends State if (!mounted || !widget.active) return; logic.session.setUserWantsPlay(true); }, + onEpisodes: () => logic.showEpisodes(context), onMore: _openCurrentMore, onScrubStart: ready ? () => logic.beginScrub(videoPort!) : null, onSeek: ready diff --git a/lib/pages/player/widgets/home_feed_overlay.dart b/lib/pages/player/widgets/home_feed_overlay.dart index 26a7d53..9ebfa79 100644 --- a/lib/pages/player/widgets/home_feed_overlay.dart +++ b/lib/pages/player/widgets/home_feed_overlay.dart @@ -9,30 +9,23 @@ import 'package:video_player/video_player.dart'; import '../../../core/data/models/drama.dart'; import '../../../core/data/services/playback_thumbnail_service.dart'; import '../../../core/data/services/sprite_thumbnail_axis.dart'; -import '../../../core/formatting/count_format.dart'; -import '../../../core/formatting/localized_content.dart'; import '../../../core/theme/app_colors.dart'; import '../../../l10n/app_localizations.dart'; -import 'interaction_rail.dart'; +import 'home_feed_recommend_panel.dart'; import 'loading_progress_track.dart'; import 'scrub_preview_card.dart'; import 'seek_progress_bar.dart'; part 'home_feed_overlay_parts.dart'; -const Color _overlayPill = AppColors.overlaySoft; -const Color _fullBarBackground = AppColors.overlayBar; -const double _titleFontSize = 17; -const double _metaFontSize = 12; -const double _tagFontSize = 11; -const double _fullBarHeight = 44; const double _progressHeight = 2; -const double _progressHitHeight = 24; +const double _progressHitHeight = 12; +const double _pageHorizontal = 14; +const double _railGap = 12; +const double _railBottomOffset = 60; const List _boostTextShadows = [ Shadow(color: AppColors.shadow, blurRadius: 5, offset: Offset(0, 1)), ]; -const int _maxTags = 4; -final RegExp _tagSplitter = RegExp(r'[,/、·]'); /// 首页沉浸式 feed 浮层:手势层、右侧互动栏、底部信息与整集进度条。 class HomeFeedOverlay extends StatefulWidget { @@ -53,6 +46,7 @@ class HomeFeedOverlay extends StatefulWidget { required this.onShare, required this.onDetail, required this.onOpenFullPlayer, + this.onEpisodes, required this.onTogglePlay, required this.onBoostStart, required this.onBoostEnd, @@ -86,6 +80,7 @@ class HomeFeedOverlay extends StatefulWidget { final VoidCallback onShare; final VoidCallback onDetail; final VoidCallback onOpenFullPlayer; + final VoidCallback? onEpisodes; final VoidCallback onTogglePlay; final VoidCallback onBoostStart; final VoidCallback onBoostEnd; @@ -124,17 +119,42 @@ class _HomeFeedOverlayState extends State { ), ), if (widget.boosting) - Column(children: [const Spacer(), _boostHint(), _fullSeriesBar()]) - else - Column( - children: [ - const Spacer(), - if (widget.visible) _bottomInfo(), - _fullSeriesBar(), - ], + Positioned(left: 0, right: 0, bottom: 20, child: _boostHint()) + else if (widget.visible) + Positioned( + left: _pageHorizontal, + right: _pageHorizontal, + bottom: 12, + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded(child: _bottomInfo()), + const SizedBox(width: _railGap), + Padding( + padding: const EdgeInsets.only(bottom: _railBottomOffset), + child: _rail(), + ), + ], + ), ), - if (widget.visible && !widget.boosting) - Positioned(right: 10, bottom: 56, child: _rail()), + Positioned( + left: 0, + right: 0, + bottom: 0, + child: _FeedProgressBar( + controller: widget.controller, + locked: widget.locked, + height: _progressHeight, + hitHeight: _progressHitHeight, + onScrubStart: widget.onScrubStart, + onSeek: widget.onSeek, + thumbnailService: widget.thumbnailService, + spriteAxisService: widget.spriteAxisService, + thumbnailsUrl: widget.episode.thumbnailsUrl, + episodeId: widget.episode.id, + posterUrl: widget.drama.posterUrl, + ), + ), ], ), ); @@ -179,244 +199,23 @@ class _HomeFeedOverlayState extends State { } Widget _rail() { - return InteractionRail( - items: [ - InteractionRailItem( - key: const Key('rail-save'), - asset: 'assets/icon/rail/rail_save.png', - label: widget.l10n.favorite, - count: widget.favoriteCount, - countKey: const Key('rail-save-count'), - selected: widget.saved, - onTap: widget.onSave, - ), - InteractionRailItem( - key: const Key('rail-comments'), - asset: 'assets/icon/rail/rail_comment.png', - label: widget.l10n.comments, - count: widget.commentCount, - countKey: const Key('rail-comments-count'), - onTap: widget.onComments, - ), - InteractionRailItem( - key: const Key('rail-like'), - asset: 'assets/icon/rail/rail_like.png', - label: widget.l10n.like, - count: widget.likeCount, - countKey: const Key('rail-like-count'), - selected: widget.liked, - onTap: widget.onLike, - ), - InteractionRailItem( - key: const Key('rail-share'), - asset: 'assets/icon/rail/rail_share.png', - label: widget.l10n.share, - onTap: widget.onShare, - ), - ], + return HomeFeedActionRail( + saved: widget.saved, + liked: widget.liked, + favoriteCount: widget.favoriteCount, + likeCount: widget.likeCount, + onSave: widget.onSave, + onLike: widget.onLike, + onEpisodes: widget.onEpisodes ?? widget.onOpenFullPlayer, ); } Widget _bottomInfo() { - return Padding( - padding: const EdgeInsets.fromLTRB(14, 0, 56, 8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - _seriesMetaRow(), - const SizedBox(height: 2), - _titleRow(), - const SizedBox(height: 6), - _tagsRow(), - if (widget.authorNote.isNotEmpty) ...[ - const SizedBox(height: 6), - Text( - '${widget.l10n.authorNoteLabel}: ${widget.authorNote}', - key: const Key('home-feed-author-note'), - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle(color: AppColors.muted, fontSize: 11), - ), - ], - ], - ), - ); - } - - Widget _seriesMetaRow() { - return GestureDetector( - key: const Key('home-feed-series-meta'), - onTap: widget.onDetail, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Flexible( - child: Text( - widget.l10n.seriesMeta( - '${widget.drama.episodeCount}', - formatInteractionCount(widget.drama.viewCount), - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.muted, - fontSize: _metaFontSize, - ), - ), - ), - const Icon( - Icons.chevron_right_rounded, - color: AppColors.muted, - size: 16, - ), - ], - ), - ); - } - - Widget _titleRow() { - return GestureDetector( - onTap: widget.onDetail, - child: Row( - children: [ - Flexible( - child: Text( - localizedDramaTitle(widget.l10n, widget.drama), - key: const Key('home-feed-title'), - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontFamily: 'Outfit', - color: AppColors.onPrimary, - fontSize: _titleFontSize, - fontWeight: FontWeight.w800, - shadows: [Shadow(blurRadius: 8, color: AppColors.mediaBlack)], - ), - ), - ), - const Icon( - Icons.chevron_right_rounded, - color: AppColors.onPrimary, - size: 20, - ), - ], - ), - ); - } - - Widget _tagsRow() { - final tags = [ - widget.l10n.episodeLabel(widget.episode.number), - ..._tagValues(), - ].take(_maxTags).toList(); - return Wrap( - key: const Key('home-feed-tags'), - spacing: 6, - runSpacing: 4, - children: [ - for (final tag in tags) - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: _overlayPill, - borderRadius: BorderRadius.circular(6), - ), - child: Text( - tag, - maxLines: 1, - style: const TextStyle( - color: AppColors.onPrimary, - fontSize: _tagFontSize, - ), - ), - ), - ], - ); - } - - List _tagValues() { - final result = []; - for (final value in [widget.drama.genre, widget.drama.category]) { - for (final part in value.split(_tagSplitter)) { - final tag = part.trim(); - if (tag.isEmpty || result.contains(tag)) continue; - result.add(tag); - } - } - return result; - } - - Widget _fullSeriesBar() { - return GestureDetector( - key: const Key('home-feed-full-series'), - onTap: widget.onOpenFullPlayer, - child: SizedBox( - height: _fullBarHeight, - child: Stack( - clipBehavior: Clip.none, - children: [ - Positioned.fill( - child: Container( - key: const Key('home-feed-full-series-bg'), - color: _fullBarBackground, - ), - ), - Positioned.fill( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 14), - child: Row( - children: [ - const Icon( - Icons.play_arrow_rounded, - color: AppColors.onPrimary, - size: 14, - ), - const SizedBox(width: 6), - Expanded( - child: Text( - widget.l10n.watchFullSeries( - '${widget.drama.episodeCount}', - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.onPrimary, - fontSize: 13, - fontWeight: FontWeight.w600, - ), - ), - ), - const Icon( - Icons.chevron_right_rounded, - color: AppColors.onPrimary, - size: 18, - ), - ], - ), - ), - ), - Positioned( - left: 0, - right: 0, - bottom: 0, - child: _FeedProgressBar( - controller: widget.controller, - locked: widget.locked, - height: _progressHeight, - hitHeight: _progressHitHeight, - onScrubStart: widget.onScrubStart, - onSeek: widget.onSeek, - thumbnailService: widget.thumbnailService, - spriteAxisService: widget.spriteAxisService, - thumbnailsUrl: widget.episode.thumbnailsUrl, - episodeId: widget.episode.id, - posterUrl: widget.drama.posterUrl, - ), - ), - ], - ), - ), + return HomeFeedInfoPanel( + drama: widget.drama, + episode: widget.episode, + onDetail: widget.onDetail, + onOpenFullPlayer: widget.onOpenFullPlayer, ); } } diff --git a/lib/pages/player/widgets/home_feed_recommend_panel.dart b/lib/pages/player/widgets/home_feed_recommend_panel.dart new file mode 100644 index 0000000..5d681e6 --- /dev/null +++ b/lib/pages/player/widgets/home_feed_recommend_panel.dart @@ -0,0 +1,476 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../../../core/data/models/drama.dart'; +import '../../../core/formatting/count_format.dart'; +import '../../../core/formatting/localized_content.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../l10n/app_localizations.dart'; +import '../../home/widgets/home_drama_classification.dart'; + +const double _headlineFont = 26; +const double _seriesFont = 17; +const double _metaFont = 12; +const double _tagFont = 12; +const double _synopsisFont = 13; +const double _watchAllHeight = 44; +const double _actionGap = 28; +const double _actionIcon = 32; +const double _actionWidth = 64; +const List _textShadows = [ + Shadow(color: AppColors.shadow, blurRadius: 6, offset: Offset(0, 1)), +]; +final RegExp _tagSplitter = RegExp(r'[,/、·]'); + +class HomeFeedInfoPanel extends StatefulWidget { + const HomeFeedInfoPanel({ + super.key, + required this.drama, + required this.episode, + required this.onDetail, + required this.onOpenFullPlayer, + }); + + final Drama drama; + final Episode episode; + final VoidCallback onDetail; + final VoidCallback onOpenFullPlayer; + + @override + State createState() => _HomeFeedInfoPanelState(); +} + +class _HomeFeedInfoPanelState extends State { + bool _synopsisExpanded = false; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final dramaTitle = localizedDramaTitle(l10n, widget.drama); + final episodeTitle = widget.episode.title.trim(); + final headline = episodeTitle.isEmpty ? dramaTitle : episodeTitle; + final synopsis = localizedDramaSynopsis(l10n, widget.drama).trim(); + final genres = _genreValues(); + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + headline, + key: const Key('home-feed-title'), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: _headlineFont, + fontWeight: FontWeight.w700, + height: 1.2, + letterSpacing: 0, + shadows: _textShadows, + ), + ), + const SizedBox(height: 8), + _metaPills(l10n), + if (_hasMeta()) const SizedBox(height: 8), + GestureDetector( + key: const Key('home-feed-series-name'), + behavior: HitTestBehavior.opaque, + onTap: widget.onDetail, + child: Row( + children: [ + Expanded( + child: Text( + dramaTitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: _seriesFont, + fontWeight: FontWeight.w600, + shadows: _textShadows, + ), + ), + ), + const Icon( + Icons.chevron_right_rounded, + size: 16, + color: AppColors.overlaySecondary, + ), + ], + ), + ), + const SizedBox(height: 8), + _tagMetaRow(l10n, genres), + if (synopsis.isNotEmpty) ...[ + const SizedBox(height: 8), + _synopsis(l10n, synopsis), + ], + const SizedBox(height: 12), + _watchAll(l10n), + ], + ); + } + + bool _hasMeta() { + return isHotDrama(widget.drama) || widget.drama.viewCount > 0; + } + + Widget _metaPills(AppLocalizations l10n) { + final hot = isHotDrama(widget.drama); + final views = widget.drama.viewCount; + if (!hot && views <= 0) return const SizedBox.shrink(); + return Wrap( + key: const Key('home-feed-meta-pills'), + spacing: 6, + runSpacing: 4, + children: [ + if (hot) + _pill( + key: const Key('home-feed-ranking-pill'), + background: AppColors.error, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.local_fire_department_rounded, + size: 12, + color: AppColors.textPrimary, + ), + const SizedBox(width: 2), + Text( + l10n.rankingHot, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: _metaFont, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + if (views > 0) + _pill( + key: const Key('home-feed-view-pill'), + background: AppColors.overlaySoft, + child: Text( + formatInteractionCount(views), + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: _metaFont, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ); + } + + Widget _pill({ + required Key key, + required Color background, + required Widget child, + }) { + return Container( + key: key, + height: 22, + padding: const EdgeInsets.symmetric(horizontal: 8), + alignment: Alignment.center, + decoration: BoxDecoration( + color: background, + borderRadius: BorderRadius.circular(11), + ), + child: child, + ); + } + + Widget _tagMetaRow(AppLocalizations l10n, List genres) { + return Row( + 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: 6), + ], + Expanded( + child: Text( + genres.join(' | '), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.overlaySecondary, + fontSize: _tagFont, + ), + ), + ), + const SizedBox(width: 6), + Text( + l10n.favoriteEpisodeProgress( + widget.episode.number, + widget.drama.episodeCount, + ), + key: const Key('home-feed-episode-progress'), + style: const TextStyle( + color: AppColors.overlaySecondary, + fontSize: _metaFont, + ), + ), + ], + ); + } + + Widget _synopsis(AppLocalizations l10n, String synopsis) { + const style = TextStyle( + color: AppColors.overlaySecondary, + fontSize: _synopsisFont, + height: 1.35, + shadows: _textShadows, + ); + return LayoutBuilder( + builder: (context, constraints) { + final painter = TextPainter( + text: TextSpan(text: synopsis, style: style), + maxLines: 2, + textDirection: Directionality.of(context), + textScaler: MediaQuery.textScalerOf(context), + )..layout(maxWidth: constraints.maxWidth); + final overflows = painter.didExceedMaxLines; + if (!overflows) { + return Text( + synopsis, + key: const Key('home-feed-synopsis'), + style: style, + ); + } + if (_synopsisExpanded) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + synopsis, + key: const Key('home-feed-synopsis'), + style: style, + ), + const SizedBox(height: 2), + _synopsisToggle( + key: const Key('home-feed-synopsis-more'), + label: l10n.showLess, + ), + ], + ); + } + return Stack( + children: [ + Padding( + padding: const EdgeInsets.only(right: 40), + child: Text( + synopsis, + key: const Key('home-feed-synopsis'), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: style, + ), + ), + Positioned( + right: 0, + bottom: 0, + child: _synopsisToggle( + key: const Key('home-feed-synopsis-more'), + label: l10n.more, + ), + ), + ], + ); + }, + ); + } + + Widget _synopsisToggle({required Key key, required String label}) { + return GestureDetector( + key: key, + onTap: () => setState(() => _synopsisExpanded = !_synopsisExpanded), + child: Text( + label, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: _synopsisFont, + fontWeight: FontWeight.w600, + ), + ), + ); + } + + Widget _watchAll(AppLocalizations l10n) { + return SizedBox( + width: double.infinity, + 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() { + final result = []; + for (final value in [widget.drama.genre, widget.drama.category]) { + for (final part in value.split(_tagSplitter)) { + final tag = part.trim(); + if (tag.isEmpty || result.contains(tag)) continue; + result.add(tag); + } + } + return result.take(3).toList(growable: false); + } +} + +class HomeFeedActionRail extends StatelessWidget { + const HomeFeedActionRail({ + super.key, + required this.saved, + required this.liked, + required this.onSave, + required this.onLike, + required this.onEpisodes, + this.favoriteCount, + this.likeCount, + }); + + final bool saved; + final bool liked; + final int? favoriteCount; + final int? likeCount; + final VoidCallback onSave; + final VoidCallback onLike; + final VoidCallback onEpisodes; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return SizedBox( + width: _actionWidth, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _action( + key: const Key('rail-save'), + icon: saved + ? Icons.bookmark_rounded + : Icons.bookmark_border_rounded, + label: favoriteCount == null + ? l10n.favorite + : formatInteractionCount(favoriteCount!), + labelKey: const Key('rail-save-count'), + onTap: onSave, + ), + const SizedBox(height: _actionGap), + _action( + key: const Key('rail-like'), + icon: liked + ? Icons.favorite_rounded + : Icons.favorite_border_rounded, + label: likeCount == null + ? l10n.like + : formatInteractionCount(likeCount!), + labelKey: const Key('rail-like-count'), + onTap: onLike, + ), + const SizedBox(height: _actionGap), + _action( + key: const Key('rail-episodes'), + icon: Icons.layers_rounded, + label: l10n.episodes, + labelKey: const Key('rail-episodes-label'), + labelFontSize: 11, + onTap: onEpisodes, + ), + ], + ), + ); + } + + Widget _action({ + required Key key, + required IconData icon, + required String label, + required Key labelKey, + required VoidCallback onTap, + double labelFontSize = 12, + }) { + return GestureDetector( + key: key, + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + size: _actionIcon, + color: AppColors.textPrimary, + shadows: const [ + Shadow( + color: AppColors.shadowSoft, + blurRadius: 4, + offset: Offset(0, 1), + ), + ], + ), + const SizedBox(height: 2), + Text( + label, + key: labelKey, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: TextStyle( + color: AppColors.textPrimary, + fontSize: labelFontSize, + fontWeight: FontWeight.w500, + height: 1, + shadows: _textShadows, + ), + ), + ], + ), + ); + } +} diff --git a/lib/pages/recommend/recommend_page.dart b/lib/pages/recommend/recommend_page.dart index 803e7f7..75d2d15 100644 --- a/lib/pages/recommend/recommend_page.dart +++ b/lib/pages/recommend/recommend_page.dart @@ -13,14 +13,13 @@ import '../../core/data/services/home_feed_controller.dart'; import '../../core/data/services/wallet_controller.dart'; import '../../core/navigation/fade_page_route.dart'; import '../../core/theme/app_colors.dart'; -import '../../core/theme/app_dimens.dart'; import '../../core/widgets/empty_state.dart'; import '../../l10n/app_localizations.dart'; import '../player/player_view.dart'; +import 'recommend_types.dart'; import 'widgets/all_dramas_filter_grid.dart'; import 'widgets/recommend_sections_view.dart'; - -enum RecommendInnerTab { feed, playlists, allDramas } +import 'widgets/recommend_top_nav.dart'; class RecommendPage extends StatefulWidget { const RecommendPage({ @@ -50,38 +49,49 @@ class _RecommendPageState extends State { return ColoredBox( key: const Key('recommend-page'), color: AppColors.appBackground, - child: SafeArea( - bottom: false, - child: Column( - children: [ - _header(l10n), - Expanded( - child: ListenableBuilder( - listenable: Listenable.merge([ - widget.feedController, - categories, - ]), - builder: (context, _) => IndexedStack( - index: _tab.index, - children: [ - _recommendFeed(context), + child: Stack( + children: [ + Positioned.fill( + child: ListenableBuilder( + listenable: Listenable.merge([widget.feedController, categories]), + builder: (context, _) => IndexedStack( + index: _tab.index, + children: [ + _recommendFeed(context), + _withTopNavSpace( RecommendSectionsView( feedController: widget.feedController, categories: categories, onOpenDrama: _openDrama, onMore: _handleMore, ), + ), + _withTopNavSpace( AllDramasFilterGrid( feedController: widget.feedController, onOpenDrama: _openDrama, onOpenFilter: widget.onOpenFilter, ), - ], - ), + ), + ], ), ), - ], - ), + ), + Positioned( + top: 0, + left: 0, + right: 0, + child: SafeArea( + bottom: false, + child: RecommendTopNav( + selected: _tab, + onVideo: _tab == RecommendInnerTab.feed, + onSelected: (tab) => setState(() => _tab = tab), + onSearch: widget.onSearch, + ), + ), + ), + ], ), ); } @@ -129,88 +139,12 @@ class _RecommendPageState extends State { ); } - Widget _header(AppLocalizations l10n) { - final items = [ - (RecommendInnerTab.feed, l10n.recommendTopTab, 'recommend'), - (RecommendInnerTab.playlists, l10n.playlistTopTab, 'playlist'), - (RecommendInnerTab.allDramas, l10n.allDramasTopTab, 'all'), - ]; - return SizedBox( - key: const Key('recommend-top-nav'), - height: 56, + Widget _withTopNavSpace(Widget child) { + return SafeArea( + bottom: false, child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: AppDimens.feedPageHorizontal, - ), - child: Row( - children: [ - Expanded( - child: Row( - children: [ - for (final item in items) - Expanded( - child: _tabButton( - key: Key('recommend-tab-${item.$3}'), - label: item.$2, - selected: _tab == item.$1, - onTap: () => setState(() => _tab = item.$1), - ), - ), - ], - ), - ), - const SizedBox(width: AppDimens.spaceSm), - IconButton( - key: const Key('recommend-search'), - tooltip: l10n.search, - onPressed: widget.onSearch, - iconSize: 24, - color: AppColors.textPrimary, - icon: const Icon(Icons.search_rounded), - ), - ], - ), - ), - ); - } - - Widget _tabButton({ - required Key key, - required String label, - required bool selected, - required VoidCallback onTap, - }) { - return InkWell( - key: key, - onTap: onTap, - child: Padding( - padding: const EdgeInsets.only(right: AppDimens.spaceSm), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: selected - ? AppColors.textPrimary - : AppColors.textTertiary, - fontSize: AppDimens.homePrimaryTabFont, - fontWeight: selected ? FontWeight.w700 : FontWeight.w400, - ), - ), - const SizedBox(height: 3), - Container( - width: selected ? 20 : 0, - height: 2, - decoration: BoxDecoration( - color: AppColors.textPrimary, - borderRadius: BorderRadius.circular(AppDimens.pillRadius), - ), - ), - ], - ), + padding: const EdgeInsets.only(top: RecommendTopNav.height), + child: child, ), ); } diff --git a/lib/pages/recommend/recommend_types.dart b/lib/pages/recommend/recommend_types.dart new file mode 100644 index 0000000..8c75b4a --- /dev/null +++ b/lib/pages/recommend/recommend_types.dart @@ -0,0 +1,3 @@ +// Author: fruit.com.cn + +enum RecommendInnerTab { feed, playlists, allDramas } diff --git a/lib/pages/recommend/widgets/recommend_top_nav.dart b/lib/pages/recommend/widgets/recommend_top_nav.dart new file mode 100644 index 0000000..328b554 --- /dev/null +++ b/lib/pages/recommend/widgets/recommend_top_nav.dart @@ -0,0 +1,124 @@ +// Author: fruit.com.cn + +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 _tabFont = 17; +const double _searchHitSize = 40; +const double _searchIconSize = 26; + +class RecommendTopNav extends StatelessWidget { + const RecommendTopNav({ + super.key, + required this.selected, + required this.onSelected, + required this.onSearch, + this.onVideo = false, + }); + + static const double height = _navHeight; + + final RecommendInnerTab selected; + final ValueChanged onSelected; + final VoidCallback onSearch; + final bool onVideo; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final items = [ + (RecommendInnerTab.feed, l10n.recommendTopTab, 'recommend'), + (RecommendInnerTab.playlists, l10n.playlistTopTab, 'playlist'), + (RecommendInnerTab.allDramas, l10n.allDramasTopTab, 'all'), + ]; + final shadows = onVideo + ? const [Shadow(color: AppColors.mediaBlack, blurRadius: 6)] + : null; + 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: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (var index = 0; index < items.length; index++) ...[ + if (index > 0) const SizedBox(width: _tabGap), + _tab( + key: Key('recommend-tab-${items[index].$3}'), + label: items[index].$2, + selected: selected == items[index].$1, + shadows: shadows, + onTap: () => onSelected(items[index].$1), + ), + ], + ], + ), + 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), + ), + ), + ), + ], + ), + ), + ); + } + + Widget _tab({ + required Key key, + required String label, + required bool selected, + required List? shadows, + required VoidCallback onTap, + }) { + return GestureDetector( + key: key, + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: SizedBox( + height: height, + child: Center( + child: Text( + label, + maxLines: 1, + style: TextStyle( + color: selected + ? AppColors.textPrimary + : AppColors.textTabInactive, + fontSize: _tabFont, + fontWeight: selected ? FontWeight.w700 : FontWeight.w400, + letterSpacing: 0, + shadows: shadows, + ), + ), + ), + ), + ); + } +} diff --git a/test/home/home_preview_layout_test.dart b/test/home/home_preview_layout_test.dart index 5382579..f198ff7 100644 --- a/test/home/home_preview_layout_test.dart +++ b/test/home/home_preview_layout_test.dart @@ -291,7 +291,10 @@ void main() { const drama = Drama( id: 'feed-drama', title: 'Feed Drama', - synopsis: 'synopsis', + synopsis: + 'A long synopsis follows the heroine through a dangerous city, ' + 'a hidden identity, and a final choice that changes everyone around ' + 'her forever.', posterUrl: '', genre: 'Romance', category: 'Urban', @@ -302,6 +305,7 @@ void main() { favoriteCount: 32000, commentCount: 12, ); + var episodesOpened = 0; await tester.pumpWidget( MaterialApp( theme: AppTheme.light(), @@ -337,6 +341,7 @@ void main() { onShare: () {}, onDetail: () {}, onOpenFullPlayer: () {}, + onEpisodes: () => episodesOpened++, onMore: () {}, ), ), @@ -347,13 +352,28 @@ void main() { expect( tester.widget(find.byKey(const Key('home-feed-title'))).data, - 'Feed Drama', + 'Ignored Episode Title', ); + expect(find.text('Feed Drama'), findsOneWidget); expect(find.byKey(const Key('home-feed-info-card')), findsNothing); expect(find.byKey(const Key('home-feed-full-series')), findsOneWidget); expect(find.byKey(const Key('home-feed-tags')), findsOneWidget); - expect(find.text('Episode 2'), findsOneWidget); + expect(find.byKey(const Key('home-feed-synopsis-more')), findsOneWidget); + await tester.tap(find.byKey(const Key('home-feed-synopsis-more'))); + await tester.pump(); + expect( + tester.widget(find.byKey(const Key('home-feed-synopsis'))).maxLines, + isNull, + ); + expect( + tester + .widget(find.byKey(const Key('home-feed-episode-progress'))) + .data, + 'Episode 2 / 8 total', + ); expect(find.textContaining('41.9万'), findsOneWidget); + expect(find.byKey(const Key('home-feed-ranking-pill')), findsOneWidget); + expect(find.byKey(const Key('home-feed-view-pill')), findsOneWidget); expect( tester.widget(find.byKey(const Key('rail-like-count'))).data, '1006', @@ -363,43 +383,38 @@ void main() { '3.2万', ); expect( - tester.widget(find.byKey(const Key('rail-comments-count'))).data, - '12', - ); - String railAsset(Key key) { - final image = tester.widget( - find.descendant(of: find.byKey(key), matching: find.byType(Image)), - ); - return (image.image as AssetImage).assetName; - } - - expect(railAsset(const Key('rail-save')), 'assets/icon/rail/rail_save.png'); - expect( - railAsset(const Key('rail-comments')), - 'assets/icon/rail/rail_comment.png', - ); - expect(railAsset(const Key('rail-like')), 'assets/icon/rail/rail_like.png'); - expect( - railAsset(const Key('rail-share')), - 'assets/icon/rail/rail_share.png', + find.descendant( + of: find.byKey(const Key('rail-like')), + matching: find.byIcon(Icons.favorite_rounded), + ), + findsOneWidget, ); expect( - tester - .widget( - find.descendant( - of: find.byKey(const Key('rail-share')), - matching: find.byType(Image), - ), - ) - .colorBlendMode, - BlendMode.srcIn, + find.descendant( + of: find.byKey(const Key('rail-save')), + matching: find.byIcon(Icons.bookmark_border_rounded), + ), + findsOneWidget, ); + expect(find.byKey(const Key('rail-episodes')), findsOneWidget); + expect( + find.descendant( + of: find.byKey(const Key('rail-episodes')), + matching: find.byIcon(Icons.layers_rounded), + ), + findsOneWidget, + ); + expect(find.byKey(const Key('rail-comments')), findsNothing); + expect(find.byKey(const Key('rail-share')), findsNothing); + expect(find.text('Comments'), findsNothing); expect(find.byKey(const Key('progress-loading')), findsNothing); expect( find.byKey(const Key('home-feed-progress-inactive')), findsOneWidget, ); expect(find.byKey(const Key('home-feed-progress-thumb')), findsNothing); + await tester.tap(find.byKey(const Key('rail-episodes'))); + expect(episodesOpened, 1); }); testWidgets('should_show_static_track_without_seek_when_feed_is_locked', ( @@ -597,14 +612,19 @@ void main() { final bar = tester.getRect(find.byKey(const Key('home-feed-full-series'))); expect(bar.height, 44); + final buttonBackground = + tester + .widget( + find.byKey(const Key('home-feed-full-series-bg')), + ) + .decoration + as BoxDecoration; + expect((buttonBackground.gradient! as LinearGradient).colors, [ + AppColors.actionRed, + AppColors.blue, + ]); expect( - tester - .widget(find.byKey(const Key('home-feed-full-series-bg'))) - .color, - const Color(0x4D000000), - ); - expect( - tester.getRect(find.textContaining('Watch full series')).center.dy, + tester.getRect(find.text('Watch all')).center.dy, closeTo(bar.center.dy, 2), ); expect( @@ -613,7 +633,7 @@ void main() { ); expect( tester.getSize(find.byKey(const Key('home-feed-progress'))).height, - greaterThanOrEqualTo(24), + 12, ); expect( tester diff --git a/test/recommend/recommend_sections_test.dart b/test/recommend/recommend_sections_test.dart index 4f7e973..d7aff3d 100644 --- a/test/recommend/recommend_sections_test.dart +++ b/test/recommend/recommend_sections_test.dart @@ -35,6 +35,55 @@ void main() { expect(find.byKey(const Key('recommend-tab-playlist')), findsOneWidget); expect(find.byKey(const Key('recommend-tab-all')), findsOneWidget); expect(find.byKey(const Key('recommend-feed-player')), findsOneWidget); + final recommendText = tester.widget( + find.descendant( + of: find.byKey(const Key('recommend-tab-recommend')), + matching: find.text('Recommend'), + ), + ); + expect(recommendText.style?.fontSize, 17); + expect(recommendText.style?.fontWeight, FontWeight.w700); + expect(recommendText.style?.color, AppColors.textPrimary); + final playlistText = tester.widget( + find.descendant( + of: find.byKey(const Key('recommend-tab-playlist')), + matching: find.text('Playlists'), + ), + ); + expect(playlistText.style?.fontSize, 17); + expect(playlistText.style?.fontWeight, FontWeight.w400); + expect(playlistText.style?.color, AppColors.textTabInactive); + expect( + tester + .widget(find.byKey(const Key('recommend-search'))) + .iconSize, + 26, + ); + final topNavRect = tester.getRect( + find.byKey(const Key('recommend-top-nav')), + ); + expect( + tester.getRect(find.byKey(const Key('recommend-search'))).right, + closeTo(topNavRect.right - AppDimens.feedPageHorizontal, 0.1), + ); + final tabGroupLeft = tester + .getRect(find.byKey(const Key('recommend-tab-recommend'))) + .left; + final tabGroupRight = tester + .getRect(find.byKey(const Key('recommend-tab-all'))) + .right; + expect( + (tabGroupLeft + tabGroupRight) / 2, + closeTo(topNavRect.center.dx, 0.1), + ); + expect( + tester + .getRect(find.byKey(const Key('recommend-top-nav'))) + .overlaps( + tester.getRect(find.byKey(const Key('recommend-feed-player'))), + ), + isTrue, + ); final player = tester.widget( find.byKey(const Key('recommend-feed-player')), );