merge: 对齐沉浸式推荐页视觉布局
This commit is contained in:
commit
8737937875
17 files changed
+831
-398
No files matched your search
@@ -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。
|
||||
@@ -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);
|
||||
|
||||
@@ -748,5 +748,6 @@
|
||||
},
|
||||
"watchedToEpisode": "Watched to Episode {episode}",
|
||||
"@watchedToEpisode": { "placeholders": { "episode": { "type": "int" } } },
|
||||
"homeWatchAll": "Watch all",
|
||||
"confirmRemove": "Remove this item?"
|
||||
}
|
||||
@@ -705,5 +705,6 @@
|
||||
},
|
||||
"watchedToEpisode": "Ditonton hingga Episode {episode}",
|
||||
"@watchedToEpisode": { "placeholders": { "episode": { "type": "int" } } },
|
||||
"homeWatchAll": "Tonton semua",
|
||||
"confirmRemove": "Hapus item ini?"
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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?';
|
||||
}
|
||||
@@ -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?';
|
||||
}
|
||||
@@ -1675,6 +1675,9 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
return '已看至 第 $episode 集';
|
||||
}
|
||||
|
||||
@override
|
||||
String get homeWatchAll => '观看全集';
|
||||
|
||||
@override
|
||||
String get confirmRemove => '确定移除这条内容吗?';
|
||||
}
|
||||
@@ -667,5 +667,6 @@
|
||||
},
|
||||
"watchedToEpisode": "已看至 第 {episode} 集",
|
||||
"@watchedToEpisode": { "placeholders": { "episode": { "type": "int" } } },
|
||||
"homeWatchAll": "观看全集",
|
||||
"confirmRemove": "确定移除这条内容吗?"
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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')),
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user