merge: 对齐沉浸式推荐页视觉布局

This commit is contained in:
Zhou Weidong committed 2026-10-22 19:42:33 +08:00
commit 8737937875
17 files changed
+831 -398

No files matched your search

+7
View File
@@ -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。
+2
View File
@@ -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);
+1
View File
@@ -748,5 +748,6 @@
},
"watchedToEpisode": "Watched to Episode {episode}",
"@watchedToEpisode": { "placeholders": { "episode": { "type": "int" } } },
"homeWatchAll": "Watch all",
"confirmRemove": "Remove this item?"
}
+1
View File
@@ -705,5 +705,6 @@
},
"watchedToEpisode": "Ditonton hingga Episode {episode}",
"@watchedToEpisode": { "placeholders": { "episode": { "type": "int" } } },
"homeWatchAll": "Tonton semua",
"confirmRemove": "Hapus item ini?"
}
+6
View File
@@ -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:
+3
View File
@@ -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?';
}
+3
View File
@@ -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?';
}
+3
View File
@@ -1675,6 +1675,9 @@ class AppLocalizationsZh extends AppLocalizations {
return '已看至 第 $episode 集';
}
@override
String get homeWatchAll => '观看全集';
@override
String get confirmRemove => '确定移除这条内容吗?';
}
+1
View File
@@ -667,5 +667,6 @@
},
"watchedToEpisode": "已看至 第 {episode} 集",
"@watchedToEpisode": { "placeholders": { "episode": { "type": "int" } } },
"homeWatchAll": "观看全集",
"confirmRemove": "确定移除这条内容吗?"
}
+1
View File
@@ -621,6 +621,7 @@ class _PlayerPageState extends State<PlayerPage>
if (!mounted || !widget.active) return;
logic.session.setUserWantsPlay(true);
},
onEpisodes: () => logic.showEpisodes(context),
onMore: _openCurrentMore,
onScrubStart: ready ? () => logic.beginScrub(videoPort!) : null,
onSeek: ready
+55 -256
View File
@@ -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<Shadow> _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<HomeFeedOverlay> {
),
),
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<HomeFeedOverlay> {
}
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<String> _tagValues() {
final result = <String>[];
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,
);
}
}
@@ -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<Shadow> _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<HomeFeedInfoPanel> createState() => _HomeFeedInfoPanelState();
}
class _HomeFeedInfoPanelState extends State<HomeFeedInfoPanel> {
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<String> 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<String> _genreValues() {
final result = <String>[];
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,
),
),
],
),
);
}
}
+37 -103
View File
@@ -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<RecommendPage> {
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<RecommendPage> {
);
}
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,
),
);
}
+3
View File
@@ -0,0 +1,3 @@
// Author: fruit.com.cn
enum RecommendInnerTab { feed, playlists, allDramas }
@@ -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<RecommendInnerTab> 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<Shadow>? 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,
),
),
),
),
);
}
}
+59 -39
View File
@@ -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<Text>(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<Text>(find.byKey(const Key('home-feed-synopsis'))).maxLines,
isNull,
);
expect(
tester
.widget<Text>(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<Text>(find.byKey(const Key('rail-like-count'))).data,
'1006',
@@ -363,43 +383,38 @@ void main() {
'3.2万',
);
expect(
tester.widget<Text>(find.byKey(const Key('rail-comments-count'))).data,
'12',
);
String railAsset(Key key) {
final image = tester.widget<Image>(
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<Image>(
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<DecoratedBox>(
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<Container>(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
@@ -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<Text>(
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<Text>(
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<IconButton>(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<PlayerPage>(
find.byKey(const Key('recommend-feed-player')),
);