feat(player): 重做播放页信息层级与互动栏点击动效
This commit is contained in:
1 parent
aed9ee2b01
commit
fd2ba0bf43
15 files changed
+2074
-551
No files matched your search
@@ -0,0 +1,166 @@
|
||||
// Author: fruit.com.cn
|
||||
// 职责:推荐流与播放页共用的简介样式、固定切换入口与高度过渡。
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
|
||||
const double _synopsisFont = 15;
|
||||
const double _synopsisLineHeight = 1.4;
|
||||
const int _collapsedSynopsisLines = 2;
|
||||
const double _synopsisToggleWidthFraction = 0.55;
|
||||
const double _synopsisToggleGap = 6;
|
||||
const _synopsisMotion = Duration(milliseconds: 240);
|
||||
const _synopsisStyle = TextStyle(
|
||||
color: AppColors.overlaySecondary,
|
||||
fontSize: _synopsisFont,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: _synopsisLineHeight,
|
||||
);
|
||||
const _synopsisToggleStyle = TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: _synopsisFont,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: _synopsisLineHeight,
|
||||
);
|
||||
|
||||
/// 两行折叠的简介,展开与收起入口在右下共用同一占位。
|
||||
class ExpandableSynopsis extends StatefulWidget {
|
||||
/// [keyPrefix] 区分页内标识,[expandedFooter] 仅在展开时展示。
|
||||
const ExpandableSynopsis({
|
||||
super.key,
|
||||
required this.synopsis,
|
||||
this.keyPrefix = 'home-feed',
|
||||
this.expandedFooter,
|
||||
});
|
||||
|
||||
/// 按当前语言获取的简介正文。
|
||||
final String synopsis;
|
||||
|
||||
/// 当前页面的组件标识前缀。
|
||||
final String keyPrefix;
|
||||
|
||||
/// 展开后附加的声明等内容。
|
||||
final Widget? expandedFooter;
|
||||
|
||||
@override
|
||||
State<ExpandableSynopsis> createState() => _ExpandableSynopsisState();
|
||||
}
|
||||
|
||||
class _ExpandableSynopsisState extends State<ExpandableSynopsis> {
|
||||
bool _expanded = false;
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant ExpandableSynopsis oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.synopsis != widget.synopsis) _expanded = false;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (widget.synopsis.trim().isEmpty) return const SizedBox.shrink();
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
if (!_needsToggle(constraints.maxWidth)) return _body();
|
||||
return Row(
|
||||
key: _key('layout'),
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: _toggle,
|
||||
child: _animatedBody(),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: _synopsisToggleGap),
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: constraints.maxWidth * _synopsisToggleWidthFraction,
|
||||
),
|
||||
child: IntrinsicWidth(child: _toggleButton(l10n)),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
bool _needsToggle(double maxWidth) {
|
||||
final painter = _painter(widget.synopsis, _synopsisStyle);
|
||||
painter.layout(maxWidth: maxWidth);
|
||||
final overflows = painter.didExceedMaxLines;
|
||||
painter.dispose();
|
||||
return overflows;
|
||||
}
|
||||
|
||||
TextPainter _painter(String text, TextStyle style) => TextPainter(
|
||||
text: TextSpan(
|
||||
text: text,
|
||||
style: DefaultTextStyle.of(context).style.merge(style),
|
||||
),
|
||||
maxLines: _collapsedSynopsisLines,
|
||||
textDirection: Directionality.of(context),
|
||||
textScaler: MediaQuery.textScalerOf(context),
|
||||
locale: Localizations.localeOf(context),
|
||||
textHeightBehavior: DefaultTextStyle.of(context).textHeightBehavior,
|
||||
);
|
||||
|
||||
Widget _animatedBody() {
|
||||
if (MediaQuery.disableAnimationsOf(context)) return _body();
|
||||
return AnimatedSize(
|
||||
key: _key('size'),
|
||||
duration: _synopsisMotion,
|
||||
curve: Curves.easeInOutCubic,
|
||||
alignment: Alignment.topLeft,
|
||||
child: _body(),
|
||||
);
|
||||
}
|
||||
|
||||
Key _key(String suffix) => Key('${widget.keyPrefix}-synopsis-$suffix');
|
||||
|
||||
Widget _body() {
|
||||
final text = Text.rich(
|
||||
TextSpan(text: widget.synopsis, style: _synopsisStyle),
|
||||
key: _expanded ? _key('expanded') : Key('${widget.keyPrefix}-synopsis'),
|
||||
style: _synopsisStyle,
|
||||
maxLines: _expanded ? null : _collapsedSynopsisLines,
|
||||
overflow: _expanded ? TextOverflow.clip : TextOverflow.ellipsis,
|
||||
);
|
||||
if (!_expanded || widget.expandedFooter == null) return text;
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [text, widget.expandedFooter!],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _toggleButton(AppLocalizations l10n) => Semantics(
|
||||
button: true,
|
||||
expanded: _expanded,
|
||||
child: GestureDetector(
|
||||
key: _key('more'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: _toggle,
|
||||
child: IndexedStack(
|
||||
index: _expanded ? 1 : 0,
|
||||
alignment: Alignment.bottomRight,
|
||||
children: [
|
||||
_toggleLabel(l10n.expandSynopsis),
|
||||
_toggleLabel(l10n.showLess),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Widget _toggleLabel(String label) => Text(
|
||||
label,
|
||||
maxLines: _collapsedSynopsisLines,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.right,
|
||||
style: _synopsisToggleStyle,
|
||||
);
|
||||
|
||||
void _toggle() => setState(() => _expanded = !_expanded);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
// Author: fruit.com.cn
|
||||
// 职责:推荐流纯文字标签与热搜入口,处理横向滚动和长文案。
|
||||
part of 'home_feed_recommend_panel.dart';
|
||||
|
||||
const double _tagGap = 6;
|
||||
const double _searchFillAlpha = 0.1;
|
||||
const double _searchInset = 10;
|
||||
const double _searchVertical = 4;
|
||||
const double _searchIcon = 14;
|
||||
const double _searchIconGap = 4;
|
||||
const double _searchRadius = 6;
|
||||
|
||||
class _TagStrip extends StatelessWidget {
|
||||
const _TagStrip({required this.chips});
|
||||
|
||||
final List<Widget> chips;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SingleChildScrollView(
|
||||
key: const Key('home-feed-tags'),
|
||||
scrollDirection: Axis.horizontal,
|
||||
physics: const BouncingScrollPhysics(),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: [
|
||||
for (var index = 0; index < chips.length; index++) ...[
|
||||
if (index > 0) const SizedBox(width: _tagGap),
|
||||
chips[index],
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 热搜入口保留点击搜索,长语言文案省略而不挤占信息区。
|
||||
class _HotSearchPill extends StatelessWidget {
|
||||
const _HotSearchPill({required this.label, required this.onTap});
|
||||
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => GestureDetector(
|
||||
key: const Key('home-feed-hot-search-pill'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: _searchInset,
|
||||
vertical: _searchVertical,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.textPrimary.withValues(alpha: _searchFillAlpha),
|
||||
borderRadius: BorderRadius.circular(_searchRadius),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.search_rounded,
|
||||
size: _searchIcon,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
const SizedBox(width: _searchIconGap),
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textSecondary,
|
||||
fontSize: _tagFont,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: _searchIconGap),
|
||||
const Icon(
|
||||
Icons.chevron_right_rounded,
|
||||
size: _searchIcon,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _TagChip extends StatelessWidget {
|
||||
const _TagChip({super.key, required this.label});
|
||||
|
||||
final String label;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
strutStyle: _metadataStrutStyle,
|
||||
style: const TextStyle(
|
||||
color: AppColors.overlaySecondary,
|
||||
fontSize: _tagFont,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: _metadataLineHeight,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
// Author: fruit.com.cn
|
||||
// 职责:推荐视频浮层的底部信息、互动栏、进度条与手势协调。
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
@@ -27,10 +28,13 @@ const double _progressHitHeight = 16;
|
||||
const double _pageHorizontal = 20;
|
||||
const double _railRight = 14;
|
||||
const double _railGap = 12;
|
||||
const double _railWidth = 56;
|
||||
const double _infoWatchAllGap = 2;
|
||||
// 进度线贴着屏幕底部(线心距底 4pt),观看条底边只留 8pt,视觉上就是线上一根条。
|
||||
const double _watchAllBottomInset = 8;
|
||||
// 信息面板底边直接贴住观看条顶沿,中间不留缝。
|
||||
const double _bottomInfoInset = _watchAllBottomInset + homeFeedWatchAllHeight;
|
||||
// 简介与观看条留出明确间距,展开后仍保留相同的底部留白。
|
||||
const double _bottomInfoInset =
|
||||
_watchAllBottomInset + homeFeedWatchAllHeight + _infoWatchAllGap;
|
||||
const double _railBottomOffset = 40;
|
||||
const Duration _overlayFadeDuration = Duration(milliseconds: 120);
|
||||
const List<Shadow> _boostTextShadows = [
|
||||
@@ -169,7 +173,7 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
|
||||
// 否则拖拽 seek 的起点会被观看条吞掉。两块的可见区域(文字/进度线)互不遮挡。
|
||||
Positioned(
|
||||
left: _pageHorizontal,
|
||||
right: _pageHorizontal,
|
||||
right: _railRight + _railGap + _railWidth,
|
||||
bottom: _watchAllBottomInset,
|
||||
child: AnimatedOpacity(
|
||||
duration: _overlayFadeDuration,
|
||||
@@ -224,9 +228,14 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
|
||||
children: [
|
||||
Expanded(child: _bottomInfo()),
|
||||
const SizedBox(width: _railGap),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: _railBottomOffset),
|
||||
child: _rail(),
|
||||
SizedBox(
|
||||
width: _railWidth,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: _railBottomOffset,
|
||||
),
|
||||
child: _rail(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,32 +1,47 @@
|
||||
// Author: fruit.com.cn
|
||||
// 职责:推荐视频底部标题、标签、集数、简介与观看全集入口。
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../../../core/data/models/drama.dart';
|
||||
import '../../../core/data/models/locale_text.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';
|
||||
import 'expandable_synopsis.dart';
|
||||
import 'interaction_rail.dart';
|
||||
|
||||
const double _seriesFont = 17;
|
||||
const double _tagFont = 12;
|
||||
const double _synopsisFont = 14;
|
||||
part 'home_feed_info_tags.dart';
|
||||
|
||||
const double _seriesFont = 18;
|
||||
const double _tagFont = 13;
|
||||
const double _watchAllHeight = 38;
|
||||
const double _titleMetadataGap = 2;
|
||||
const double _metadataSynopsisGap = 2;
|
||||
const double _hotSearchBottomGap = 2;
|
||||
const double _metadataGap = 8;
|
||||
const int _maximumTags = 2;
|
||||
const double _episodeFont = 14;
|
||||
const double _episodeWidthFraction = 0.55;
|
||||
const double _metadataLineHeight = 1.4;
|
||||
const _metadataStrutStyle = StrutStyle(
|
||||
fontSize: _episodeFont,
|
||||
height: _metadataLineHeight,
|
||||
forceStrutHeight: true,
|
||||
);
|
||||
|
||||
/// 观看条内部左右留白与圆角:外沿由 overlay 按页面边距对齐,内部不再重复内缩。
|
||||
const double _watchAllInset = 12;
|
||||
const double _watchAllRadius = 12;
|
||||
const double _watchAllRadius = 6;
|
||||
const double _watchAllFont = 16;
|
||||
const _watchAllGradient = LinearGradient(
|
||||
begin: Alignment.centerLeft,
|
||||
end: Alignment.centerRight,
|
||||
colors: [AppColors.mineStatShade, AppColors.brandRed, AppColors.vipButtonEnd],
|
||||
);
|
||||
|
||||
/// 标题、标签、简介之间的行间距:底部信息本来就挤,留 4pt 够分层。
|
||||
const double _blockGap = 4;
|
||||
const List<Shadow> _textShadows = [
|
||||
Shadow(color: AppColors.shadow, blurRadius: 6, offset: Offset(0, 1)),
|
||||
];
|
||||
final RegExp _tagSplitter = RegExp(r'[,/、·]');
|
||||
|
||||
/// 通栏「观看完整剧集」半透明白底条高度,overlay 用它安排底部避让。
|
||||
/// 「观看完整剧集」渐变按钮高度,overlay 用它安排底部避让。
|
||||
const double homeFeedWatchAllHeight = _watchAllHeight;
|
||||
|
||||
/// 播放量紧凑展示(跟随语言):zh 20万 / en 200K / id 200 rb。
|
||||
@@ -56,15 +71,13 @@ class HomeFeedInfoPanel extends StatefulWidget {
|
||||
}
|
||||
|
||||
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 synopsis = localizedDramaSynopsis(l10n, widget.drama).trim();
|
||||
final genres = _genreValues(Localizations.localeOf(context).languageCode);
|
||||
final tags = _tagChips(l10n, genres);
|
||||
final types = _typeLabels(Localizations.localeOf(context).languageCode);
|
||||
final tags = _tagChips(types);
|
||||
return Column(
|
||||
key: const Key('home-feed-info-panel'),
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -80,306 +93,108 @@ class _HomeFeedInfoPanelState extends State<HomeFeedInfoPanel> {
|
||||
),
|
||||
onTap: widget.onOpenSearch!,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
GestureDetector(
|
||||
key: const Key('home-feed-series-name'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: widget.onOpenFullPlayer,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
dramaTitle,
|
||||
key: const Key('home-feed-series-title'),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: _seriesFont,
|
||||
fontWeight: FontWeight.w400,
|
||||
shadows: _textShadows,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
const Icon(
|
||||
Icons.chevron_right_rounded,
|
||||
size: 18,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 没有标签就整行跳过,不给标题和简介之间留空档。
|
||||
if (tags.isNotEmpty) ...[
|
||||
const SizedBox(height: _blockGap),
|
||||
_tagRow(tags),
|
||||
const SizedBox(height: _hotSearchBottomGap),
|
||||
],
|
||||
_title(dramaTitle),
|
||||
const SizedBox(height: _titleMetadataGap),
|
||||
_metadata(l10n, tags),
|
||||
if (synopsis.isNotEmpty) ...[
|
||||
const SizedBox(height: _blockGap),
|
||||
_synopsis(l10n, synopsis),
|
||||
const SizedBox(height: _metadataSynopsisGap),
|
||||
ExpandableSynopsis(
|
||||
key: ValueKey(widget.drama.id),
|
||||
synopsis: synopsis,
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _tagChips(AppLocalizations l10n, List<String> genres) {
|
||||
return [
|
||||
if (isHotDrama(widget.drama))
|
||||
_TagChip(
|
||||
key: const Key('home-feed-hot-badge'),
|
||||
label: l10n.homeBadgeHot,
|
||||
filled: true,
|
||||
Widget _title(String dramaTitle) => GestureDetector(
|
||||
key: const Key('home-feed-series-name'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: widget.onOpenFullPlayer,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
dramaTitle,
|
||||
key: const Key('home-feed-series-title'),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: _seriesFont,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
),
|
||||
for (final genre in genres)
|
||||
_TagChip(key: Key('home-feed-tag-$genre'), label: genre),
|
||||
const SizedBox(width: 2),
|
||||
const Icon(
|
||||
Icons.chevron_right_rounded,
|
||||
size: 18,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
List<Widget> _tagChips(List<String> types) {
|
||||
return [
|
||||
for (final type in types)
|
||||
_TagChip(key: Key('home-feed-tag-$type'), label: type),
|
||||
];
|
||||
}
|
||||
|
||||
Widget _tagRow(List<Widget> chips) {
|
||||
return SizedBox(
|
||||
key: const Key('home-feed-tags'),
|
||||
height: 22,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
physics: const BouncingScrollPhysics(),
|
||||
shrinkWrap: true,
|
||||
itemCount: chips.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(width: 6),
|
||||
itemBuilder: (_, index) => chips[index],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _synopsis(AppLocalizations l10n, String synopsis) {
|
||||
final prefix = l10n.feedEpisodePrefix(widget.episode.number);
|
||||
final baseStyle = const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: _synopsisFont,
|
||||
height: 1.3,
|
||||
shadows: _textShadows,
|
||||
);
|
||||
final span = TextSpan(
|
||||
children: [
|
||||
TextSpan(text: prefix, style: baseStyle),
|
||||
TextSpan(
|
||||
text: ' | ',
|
||||
style: baseStyle.copyWith(
|
||||
color: AppColors.textPrimary.withValues(alpha: 0.45),
|
||||
fontSize: _synopsisFont - 2,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
TextSpan(text: synopsis, style: baseStyle),
|
||||
],
|
||||
);
|
||||
if (_synopsisExpanded) {
|
||||
return _expandedSynopsis(span, baseStyle, l10n);
|
||||
}
|
||||
Widget _metadata(AppLocalizations l10n, List<Widget> tags) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final toggle = _synopsisToggle(
|
||||
key: const Key('home-feed-synopsis-more'),
|
||||
label: l10n.expandSynopsis,
|
||||
padding: const EdgeInsets.only(left: 6),
|
||||
);
|
||||
final painter = TextPainter(
|
||||
text: span,
|
||||
final episode = Text(
|
||||
widget.drama.episodeCount > 0
|
||||
? l10n.episodeOfTotal(
|
||||
widget.episode.number,
|
||||
widget.drama.episodeCount,
|
||||
)
|
||||
: l10n.feedEpisodePrefix(widget.episode.number),
|
||||
key: const Key('home-feed-episode-meta'),
|
||||
maxLines: 1,
|
||||
textDirection: Directionality.of(context),
|
||||
textScaler: MediaQuery.textScalerOf(context),
|
||||
)..layout(maxWidth: constraints.maxWidth);
|
||||
if (!painter.didExceedMaxLines) {
|
||||
return Text.rich(
|
||||
span,
|
||||
key: const Key('home-feed-synopsis'),
|
||||
maxLines: 1,
|
||||
style: baseStyle,
|
||||
);
|
||||
}
|
||||
// 设计稿:单行省略号 + 行内固定「展开」。
|
||||
// 整行可点:真机上不必精确命中两个小字,点简介任意位置都能展开。
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => setState(() => _synopsisExpanded = true),
|
||||
child: Row(
|
||||
key: const Key('home-feed-synopsis-collapsed'),
|
||||
// 顶对齐:文字位置不变,toggle 的 bottom 留白把触控区向下扩到空白处。
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text.rich(
|
||||
span,
|
||||
key: const Key('home-feed-synopsis'),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: baseStyle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
toggle,
|
||||
],
|
||||
overflow: TextOverflow.ellipsis,
|
||||
strutStyle: _metadataStrutStyle,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: _episodeFont,
|
||||
height: _metadataLineHeight,
|
||||
),
|
||||
);
|
||||
return Row(
|
||||
key: const Key('home-feed-metadata'),
|
||||
children: [
|
||||
if (tags.isNotEmpty) ...[
|
||||
Flexible(child: _TagStrip(chips: tags)),
|
||||
const SizedBox(width: _metadataGap),
|
||||
],
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: tags.isEmpty
|
||||
? constraints.maxWidth
|
||||
: constraints.maxWidth * _episodeWidthFraction,
|
||||
),
|
||||
child: episode,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _expandedSynopsis(
|
||||
InlineSpan span,
|
||||
TextStyle style,
|
||||
AppLocalizations l10n,
|
||||
) {
|
||||
// 「收起」内联在简介最后一行末尾,避免独占一行导致多行简介与进度条间
|
||||
// 出现大片空白;垂直方向只留少量触控留白(仅撑高最后一行)。
|
||||
final expandedSpan = TextSpan(
|
||||
style: style,
|
||||
children: [
|
||||
span,
|
||||
WidgetSpan(
|
||||
alignment: PlaceholderAlignment.baseline,
|
||||
baseline: TextBaseline.alphabetic,
|
||||
child: _synopsisToggle(
|
||||
key: const Key('home-feed-synopsis-more'),
|
||||
label: l10n.showLess,
|
||||
padding: const EdgeInsets.only(left: 8),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
return Text.rich(
|
||||
expandedSpan,
|
||||
key: const Key('home-feed-synopsis-expanded'),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _synopsisToggle({
|
||||
required Key key,
|
||||
required String label,
|
||||
required EdgeInsets padding,
|
||||
}) {
|
||||
return GestureDetector(
|
||||
key: key,
|
||||
// opaque + 留白扩大命中区;留白方向由调用方决定,
|
||||
// 展开态不能加 top padding,否则「收起」距简介正文过远。
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => setState(() => _synopsisExpanded = !_synopsisExpanded),
|
||||
child: Padding(
|
||||
padding: padding,
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: _synopsisFont,
|
||||
fontWeight: FontWeight.w600,
|
||||
shadows: _textShadows,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<String> _genreValues(String languageCode) {
|
||||
final localized = widget.drama.localizedTypeLabels(languageCode);
|
||||
if (localized.isNotEmpty) return localized.take(3).toList(growable: false);
|
||||
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);
|
||||
}
|
||||
List<String> _typeLabels(String languageCode) => widget.drama.types
|
||||
.take(_maximumTags)
|
||||
.map((type) => localizedText(languageCode, '', type).trim())
|
||||
.where((label) => label.isNotEmpty)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
/// 热搜榜降级胶囊:🔍 共 xxx 人在看 ›(暂无榜单接口,点击跳搜索页)。
|
||||
class _HotSearchPill extends StatelessWidget {
|
||||
const _HotSearchPill({required this.label, required this.onTap});
|
||||
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
key: const Key('home-feed-hot-search-pill'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.textPrimary.withValues(alpha: 0.14),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.search_rounded,
|
||||
size: 15,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
const SizedBox(width: 5),
|
||||
Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
const Icon(
|
||||
Icons.chevron_right_rounded,
|
||||
size: 16,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 热剧红胶囊 / 类型深色半透明填充胶囊。
|
||||
class _TagChip extends StatelessWidget {
|
||||
const _TagChip({super.key, required this.label, this.filled = false});
|
||||
|
||||
final String label;
|
||||
final bool filled;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: filled
|
||||
? AppColors.brandRed
|
||||
: AppColors.textPrimary.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: _tagFont,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.2,
|
||||
shadows: _textShadows,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 底部「观看完整剧集」条:浮在底部,不再铺满整屏宽度。
|
||||
/// 左侧「观看全集」条:与信息区等宽,居中文字避免与右侧互动栏争抢。
|
||||
class HomeFeedWatchAllBar extends StatelessWidget {
|
||||
const HomeFeedWatchAllBar({
|
||||
super.key,
|
||||
@@ -395,49 +210,43 @@ class HomeFeedWatchAllBar extends StatelessWidget {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(_watchAllRadius),
|
||||
child: Material(
|
||||
key: const Key('home-feed-full-series-bg'),
|
||||
color: AppColors.textPrimary.withValues(alpha: 0.15),
|
||||
child: InkWell(
|
||||
key: const Key('home-feed-full-series'),
|
||||
onTap: onTap,
|
||||
child: SizedBox(
|
||||
height: _watchAllHeight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: _watchAllInset),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.movie_rounded,
|
||||
size: 22,
|
||||
color: AppColors.onPrimary,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.watchFullSeries('${drama.episodeCount}'),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Icon(
|
||||
Icons.chevron_right_rounded,
|
||||
size: 22,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: DecoratedBox(
|
||||
key: const Key('home-feed-full-series-gradient'),
|
||||
decoration: const BoxDecoration(gradient: _watchAllGradient),
|
||||
child: Material(
|
||||
key: const Key('home-feed-full-series-bg'),
|
||||
color: AppColors.transparent,
|
||||
child: InkWell(
|
||||
key: const Key('home-feed-full-series'),
|
||||
onTap: onTap,
|
||||
child: _content(l10n),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _content(AppLocalizations l10n) => SizedBox(
|
||||
width: double.infinity,
|
||||
height: _watchAllHeight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: _watchAllInset),
|
||||
child: Center(
|
||||
child: Text(
|
||||
l10n.homeWatchAll,
|
||||
semanticsLabel: l10n.watchFullSeries('${drama.episodeCount}'),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: _watchAllFont,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class HomeFeedActionRail extends StatelessWidget {
|
||||
@@ -468,7 +277,7 @@ class HomeFeedActionRail extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return InteractionRail(
|
||||
itemGap: 12,
|
||||
itemGap: interactionRailCompactGap,
|
||||
buttonSize: 48,
|
||||
iconSize: 36,
|
||||
countFontSize: 15,
|
||||
@@ -481,6 +290,7 @@ class HomeFeedActionRail extends StatelessWidget {
|
||||
count: favoriteCount,
|
||||
countKey: const Key('rail-save-count'),
|
||||
selected: saved,
|
||||
animateTap: true,
|
||||
selectedColor: AppColors.brandRed,
|
||||
onTap: onSave,
|
||||
),
|
||||
@@ -499,6 +309,7 @@ class HomeFeedActionRail extends StatelessWidget {
|
||||
count: likeCount,
|
||||
countKey: const Key('rail-like-count'),
|
||||
selected: liked,
|
||||
animateTap: true,
|
||||
selectedColor: AppColors.brandRed,
|
||||
onTap: onLike,
|
||||
),
|
||||
|
||||
@@ -1,15 +1,44 @@
|
||||
// Author: fruit.com.cn
|
||||
// 职责:播放互动栏的图标、计数与不影响触控区域的点击反馈。
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/formatting/count_format.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import 'interaction_rail_burst.dart';
|
||||
|
||||
const double _defaultButtonSize = 44;
|
||||
const double _defaultIconSize = 32;
|
||||
const double _defaultCountFont = 12;
|
||||
const double _defaultIconCountGap = 2;
|
||||
const double _defaultItemGap = 24;
|
||||
const _tapDuration = Duration(milliseconds: 360);
|
||||
const double _popScale = 1.34;
|
||||
const double _cancelScale = 0.68;
|
||||
const double _popWeight = 40;
|
||||
const double _settleWeight = 60;
|
||||
final _growTween = _tapTween(_popScale);
|
||||
final _shrinkTween = _tapTween(_cancelScale);
|
||||
|
||||
TweenSequence<double> _tapTween(double target) => TweenSequence<double>([
|
||||
TweenSequenceItem(
|
||||
tween: Tween(
|
||||
begin: 1.0,
|
||||
end: target,
|
||||
).chain(CurveTween(curve: Curves.easeOutCubic)),
|
||||
weight: _popWeight,
|
||||
),
|
||||
TweenSequenceItem(
|
||||
tween: Tween(
|
||||
begin: target,
|
||||
end: 1.0,
|
||||
).chain(CurveTween(curve: Curves.easeOutCubic)),
|
||||
weight: _settleWeight,
|
||||
),
|
||||
]);
|
||||
|
||||
/// 首页与完整播放器互动项之间的紧凑间距。
|
||||
const double interactionRailCompactGap = 6;
|
||||
|
||||
/// 互动栏单项:图标、计数与点击。
|
||||
class InteractionRailItem {
|
||||
@@ -24,6 +53,7 @@ class InteractionRailItem {
|
||||
this.selected = false,
|
||||
this.selectedColor = AppColors.blue,
|
||||
this.mirrorIcon = false,
|
||||
this.animateTap = false,
|
||||
}) : assert((asset == null) != (icon == null));
|
||||
|
||||
final Key key;
|
||||
@@ -38,6 +68,9 @@ class InteractionRailItem {
|
||||
|
||||
/// 水平镜像图标(分享弯箭头用 reply 镜像得到)。
|
||||
final bool mirrorIcon;
|
||||
|
||||
/// 选中放大、取消缩小并显示周围纹路,计数和触控范围不参与缩放。
|
||||
final bool animateTap;
|
||||
}
|
||||
|
||||
/// 播放页与首页 feed 共用的右侧互动栏:收藏/评论/喜欢/分享。
|
||||
@@ -79,7 +112,7 @@ class InteractionRail extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _RailButton extends StatelessWidget {
|
||||
class _RailButton extends StatefulWidget {
|
||||
const _RailButton({
|
||||
required this.item,
|
||||
required this.buttonSize,
|
||||
@@ -94,50 +127,135 @@ class _RailButton extends StatelessWidget {
|
||||
final double countFontSize;
|
||||
final double iconCountGap;
|
||||
|
||||
@override
|
||||
State<_RailButton> createState() => _RailButtonState();
|
||||
}
|
||||
|
||||
class _RailButtonState extends State<_RailButton>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _motion;
|
||||
Color _burstColor = AppColors.onPrimary;
|
||||
bool _contracting = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_motion = AnimationController(vsync: this, duration: _tapDuration);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (MediaQuery.disableAnimationsOf(context)) _motion.reset();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_motion.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tint = item.selected ? item.selectedColor : AppColors.onPrimary;
|
||||
return Column(
|
||||
key: item.key,
|
||||
key: widget.item.key,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox.square(
|
||||
dimension: buttonSize,
|
||||
child: IconButton(
|
||||
onPressed: item.onTap,
|
||||
padding: EdgeInsets.zero,
|
||||
icon: item.asset != null
|
||||
? Image.asset(
|
||||
item.asset!,
|
||||
width: iconSize,
|
||||
height: iconSize,
|
||||
fit: BoxFit.contain,
|
||||
color: tint,
|
||||
colorBlendMode: BlendMode.srcIn,
|
||||
)
|
||||
: Transform.flip(
|
||||
flipX: item.mirrorIcon,
|
||||
child: Icon(item.icon, size: iconSize, color: tint),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(height: iconCountGap),
|
||||
Text(
|
||||
item.count == null ? item.label : formatInteractionCount(item.count!),
|
||||
key: item.countKey,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: AppColors.onPrimary,
|
||||
height: 1,
|
||||
fontSize: countFontSize,
|
||||
fontWeight: FontWeight.w600,
|
||||
shadows: const [
|
||||
Shadow(blurRadius: 6, color: AppColors.mediaBlack),
|
||||
],
|
||||
),
|
||||
dimension: widget.buttonSize,
|
||||
child: widget.item.animateTap ? _animatedButton() : _button(_icon()),
|
||||
),
|
||||
SizedBox(height: widget.iconCountGap),
|
||||
_count(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _onTap() {
|
||||
if (widget.item.animateTap && !MediaQuery.disableAnimationsOf(context)) {
|
||||
_contracting = widget.item.selected;
|
||||
_burstColor = _contracting
|
||||
? AppColors.onPrimary
|
||||
: widget.item.selectedColor;
|
||||
_motion.forward(from: 0);
|
||||
}
|
||||
widget.item.onTap();
|
||||
}
|
||||
|
||||
Widget _button(Widget icon) => IconButton(
|
||||
onPressed: _onTap,
|
||||
iconSize: widget.iconSize,
|
||||
padding: EdgeInsets.zero,
|
||||
style: IconButton.styleFrom(
|
||||
overlayColor: AppColors.transparent,
|
||||
shadowColor: AppColors.transparent,
|
||||
elevation: 0,
|
||||
splashFactory: NoSplash.splashFactory,
|
||||
),
|
||||
icon: icon,
|
||||
);
|
||||
|
||||
Widget _animatedButton() => AnimatedBuilder(
|
||||
animation: _motion,
|
||||
child: _icon(),
|
||||
builder: (context, child) => Stack(
|
||||
fit: StackFit.expand,
|
||||
alignment: Alignment.center,
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
_button(
|
||||
Transform.scale(
|
||||
scale: (_contracting ? _shrinkTween : _growTween).transform(
|
||||
_motion.value,
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
if (_motion.isAnimating)
|
||||
Positioned.fill(
|
||||
child: RailTapBurst(
|
||||
key: const Key('rail-tap-burst'),
|
||||
progress: _motion.value,
|
||||
color: _burstColor,
|
||||
contracting: _contracting,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
Widget _icon() {
|
||||
final item = widget.item;
|
||||
final tint = item.selected ? item.selectedColor : AppColors.onPrimary;
|
||||
return item.asset != null
|
||||
? Image.asset(
|
||||
item.asset!,
|
||||
width: widget.iconSize,
|
||||
height: widget.iconSize,
|
||||
fit: BoxFit.contain,
|
||||
color: tint,
|
||||
colorBlendMode: BlendMode.srcIn,
|
||||
)
|
||||
: Transform.flip(
|
||||
flipX: item.mirrorIcon,
|
||||
child: Icon(item.icon, size: widget.iconSize, color: tint),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _count() {
|
||||
final item = widget.item;
|
||||
return Text(
|
||||
item.count == null ? item.label : formatInteractionCount(item.count!),
|
||||
key: item.countKey,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: AppColors.onPrimary,
|
||||
height: 1,
|
||||
fontSize: widget.countFontSize,
|
||||
fontWeight: FontWeight.w600,
|
||||
shadows: const [Shadow(blurRadius: 6, color: AppColors.mediaBlack)],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// Author: fruit.com.cn
|
||||
// 职责:绘制收藏点赞周围的紧凑双向短线,无圆环或阴影,不参与手势。
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
const int _rayCount = 8;
|
||||
const double _startProgress = 0.1;
|
||||
const double _radiusStart = 0.34;
|
||||
const double _radiusTravel = 0.14;
|
||||
const double _rayLength = 0.07;
|
||||
const double _strokeFraction = 0.025;
|
||||
const double _rayAlpha = 0.65;
|
||||
const double _fullCircle = math.pi * 2;
|
||||
const double _rayRotation = math.pi / _rayCount;
|
||||
|
||||
/// 收藏/点赞的装饰反馈:选中外扩,取消收束。
|
||||
class RailTapBurst extends StatelessWidget {
|
||||
/// [progress] 来自按钮的点击动画,不创建独立计时器。
|
||||
const RailTapBurst({
|
||||
super.key,
|
||||
required this.progress,
|
||||
required this.color,
|
||||
required this.contracting,
|
||||
});
|
||||
|
||||
final double progress;
|
||||
final Color color;
|
||||
final bool contracting;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => IgnorePointer(
|
||||
child: CustomPaint(
|
||||
painter: _RailBurstPainter(
|
||||
progress: progress,
|
||||
color: color,
|
||||
contracting: contracting,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _RailBurstPainter extends CustomPainter {
|
||||
const _RailBurstPainter({
|
||||
required this.progress,
|
||||
required this.color,
|
||||
required this.contracting,
|
||||
});
|
||||
|
||||
final double progress;
|
||||
final Color color;
|
||||
final bool contracting;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final phase = ((progress - _startProgress) / (1 - _startProgress)).clamp(
|
||||
0.0,
|
||||
1.0,
|
||||
);
|
||||
if (phase == 0 || phase == 1) return;
|
||||
final eased = Curves.easeOutCubic.transform(phase);
|
||||
final travel = contracting ? 1 - eased : eased;
|
||||
final base = size.shortestSide;
|
||||
final radius = base * (_radiusStart + _radiusTravel * travel);
|
||||
final alpha = math.sin(math.pi * phase);
|
||||
final paint = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeCap = StrokeCap.round
|
||||
..strokeWidth = base * _strokeFraction
|
||||
..color = color.withValues(alpha: alpha * _rayAlpha);
|
||||
final center = size.center(Offset.zero);
|
||||
final length = base * _rayLength * (1 - phase);
|
||||
for (var index = 0; index < _rayCount; index++) {
|
||||
final angle = _fullCircle * index / _rayCount + _rayRotation;
|
||||
canvas.drawLine(
|
||||
center + Offset.fromDirection(angle, radius),
|
||||
center + Offset.fromDirection(angle, radius + length),
|
||||
paint,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _RailBurstPainter oldDelegate) =>
|
||||
oldDelegate.progress != progress ||
|
||||
oldDelegate.color != color ||
|
||||
oldDelegate.contracting != contracting;
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import '../../../core/formatting/localized_content.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimens.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
import 'expandable_synopsis.dart';
|
||||
import 'player_center_toggle.dart';
|
||||
import 'interaction_rail.dart';
|
||||
import 'player_overlay_models.dart';
|
||||
@@ -178,7 +179,7 @@ class PlayerPlaybackOverlay extends StatelessWidget {
|
||||
opacity: scrubbing ? 0 : 1,
|
||||
child: InteractionRail(
|
||||
key: const Key('player-interaction-rail'),
|
||||
itemGap: 12,
|
||||
itemGap: interactionRailCompactGap,
|
||||
buttonSize: 48,
|
||||
iconSize: 36,
|
||||
countFontSize: 15,
|
||||
@@ -190,6 +191,7 @@ class PlayerPlaybackOverlay extends StatelessWidget {
|
||||
count: data.favoriteCount,
|
||||
countKey: const Key('player-rail-save-count'),
|
||||
selected: data.saved,
|
||||
animateTap: true,
|
||||
onTap: actions.onSave,
|
||||
),
|
||||
InteractionRailItem(
|
||||
@@ -199,6 +201,7 @@ class PlayerPlaybackOverlay extends StatelessWidget {
|
||||
count: data.likeCount,
|
||||
countKey: const Key('player-rail-like-count'),
|
||||
selected: data.liked,
|
||||
animateTap: true,
|
||||
selectedColor: AppColors.brandRed,
|
||||
onTap: actions.onLike,
|
||||
),
|
||||
@@ -300,7 +303,6 @@ class PlayerPlaybackOverlay extends StatelessWidget {
|
||||
key: const Key('player-episode-synopsis'),
|
||||
l10n: data.l10n,
|
||||
drama: data.drama,
|
||||
episodeNumber: data.episode.number,
|
||||
synopsis: synopsis,
|
||||
authorNote: data.authorNote,
|
||||
),
|
||||
@@ -412,7 +414,7 @@ class PlayerPlaybackOverlay extends StatelessWidget {
|
||||
style: const TextStyle(
|
||||
color: AppColors.onPrimary,
|
||||
fontSize: 13.5,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w400,
|
||||
shadows: _playerTextShadows,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -118,14 +118,12 @@ class _EpisodeSynopsis extends StatefulWidget {
|
||||
super.key,
|
||||
required this.l10n,
|
||||
required this.drama,
|
||||
required this.episodeNumber,
|
||||
required this.synopsis,
|
||||
required this.authorNote,
|
||||
});
|
||||
|
||||
final AppLocalizations l10n;
|
||||
final Drama drama;
|
||||
final int episodeNumber;
|
||||
final String synopsis;
|
||||
final String authorNote;
|
||||
|
||||
@@ -134,26 +132,13 @@ class _EpisodeSynopsis extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _EpisodeSynopsisState extends State<_EpisodeSynopsis> {
|
||||
bool _expanded = false;
|
||||
String? _fullSynopsis;
|
||||
int _detailRequest = 0;
|
||||
bool _detailRequested = false;
|
||||
|
||||
/// 「第 n 集」、分隔符与简介正文共用同一套白字规格。
|
||||
static const TextStyle _lineStyle = TextStyle(
|
||||
color: AppColors.onPrimary,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.35,
|
||||
shadows: _playerTextShadows,
|
||||
);
|
||||
static const TextStyle _linkStyle = TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
shadows: _playerTextShadows,
|
||||
);
|
||||
static const int _collapsedMaxLines = 2;
|
||||
static const int _authorNoteLines = 2;
|
||||
static const double _authorNoteGap = 4;
|
||||
static const double _authorNoteFont = 10;
|
||||
|
||||
String get _synopsis => _fullSynopsis ?? widget.synopsis;
|
||||
|
||||
@@ -173,7 +158,6 @@ class _EpisodeSynopsisState extends State<_EpisodeSynopsis> {
|
||||
return;
|
||||
}
|
||||
_fullSynopsis = null;
|
||||
_expanded = false;
|
||||
_detailRequested = !widget.drama.detailLoaded;
|
||||
if (_detailRequested) unawaited(_loadFullSynopsis());
|
||||
}
|
||||
@@ -200,123 +184,26 @@ class _EpisodeSynopsisState extends State<_EpisodeSynopsis> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) => _block(constraints.maxWidth),
|
||||
return ExpandableSynopsis(
|
||||
key: ValueKey(widget.drama.id),
|
||||
keyPrefix: 'player',
|
||||
synopsis: _synopsis,
|
||||
expandedFooter: widget.authorNote.isEmpty ? null : _authorNote(),
|
||||
);
|
||||
}
|
||||
|
||||
/// 与首页推荐 feed 的简介展开/收起保持一致:
|
||||
/// 折叠单行省略 + 行尾「展开」(整行可点);展开后内联「收起」。
|
||||
Widget _block(double maxWidth) {
|
||||
final label = widget.l10n.episodeLabel(widget.episodeNumber);
|
||||
final span = TextSpan(
|
||||
style: _lineStyle,
|
||||
children: [
|
||||
TextSpan(text: label, style: _lineStyle),
|
||||
_separatorSpan(),
|
||||
TextSpan(text: _synopsis, style: _lineStyle),
|
||||
],
|
||||
);
|
||||
if (_expanded) return _expandedBlock(span);
|
||||
|
||||
final painter = TextPainter(
|
||||
text: span,
|
||||
maxLines: 1,
|
||||
textDirection: Directionality.of(context),
|
||||
textScaler: MediaQuery.textScalerOf(context),
|
||||
)..layout(maxWidth: maxWidth);
|
||||
if (!painter.didExceedMaxLines) {
|
||||
return Text.rich(
|
||||
span,
|
||||
key: const Key('player-synopsis'),
|
||||
maxLines: 1,
|
||||
style: _lineStyle,
|
||||
);
|
||||
}
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => setState(() => _expanded = true),
|
||||
child: Row(
|
||||
key: const Key('player-synopsis-collapsed'),
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text.rich(
|
||||
span,
|
||||
key: const Key('player-synopsis'),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: _lineStyle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
_toggle(
|
||||
widget.l10n.expandSynopsis,
|
||||
padding: const EdgeInsets.only(left: 6),
|
||||
),
|
||||
],
|
||||
Widget _authorNote() => Padding(
|
||||
padding: const EdgeInsets.only(top: _authorNoteGap),
|
||||
child: Text(
|
||||
widget.authorNote,
|
||||
key: const Key('player-author-note'),
|
||||
maxLines: _authorNoteLines,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.playerMuted,
|
||||
fontSize: _authorNoteFont,
|
||||
shadows: _playerTextShadows,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _expandedBlock(InlineSpan span) {
|
||||
final expandedSpan = TextSpan(
|
||||
style: _lineStyle,
|
||||
children: [
|
||||
span,
|
||||
WidgetSpan(
|
||||
alignment: PlaceholderAlignment.baseline,
|
||||
baseline: TextBaseline.alphabetic,
|
||||
child: _toggle(
|
||||
widget.l10n.showLess,
|
||||
padding: const EdgeInsets.only(left: 8),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
return Column(
|
||||
key: const Key('player-synopsis-expanded'),
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text.rich(expandedSpan, key: const Key('player-synopsis')),
|
||||
if (widget.authorNote.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
widget.authorNote,
|
||||
key: const Key('player-author-note'),
|
||||
maxLines: _collapsedMaxLines,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.playerMuted,
|
||||
fontSize: 10,
|
||||
shadows: _playerTextShadows,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 分隔竖线:同字号 w400 + 45% 透明白,靠两侧空格留白。
|
||||
/// 必须用 TextSpan:WidgetSpan 占位盒的基线对齐会让第n集与简介错位。
|
||||
TextSpan _separatorSpan() => TextSpan(
|
||||
text: ' | ',
|
||||
style: _lineStyle.copyWith(
|
||||
color: AppColors.onPrimary.withValues(alpha: 0.45),
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
);
|
||||
|
||||
Widget _toggle(String label, {required EdgeInsets padding}) {
|
||||
return GestureDetector(
|
||||
key: const Key('player-synopsis-toggle'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => setState(() => _expanded = !_expanded),
|
||||
child: Padding(
|
||||
padding: padding,
|
||||
child: Text(label, maxLines: 1, style: _linkStyle),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,467 @@
|
||||
// Author: fruit.com.cn
|
||||
// 职责:验证推荐视频底部信息的无阴影、视觉层级与观看条留白。
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fruitshorts/core/data/models/drama.dart';
|
||||
import 'package:fruitshorts/core/theme/app_colors.dart';
|
||||
import 'package:fruitshorts/core/theme/app_theme.dart';
|
||||
import 'package:fruitshorts/l10n/app_localizations.dart';
|
||||
import 'package:fruitshorts/pages/player/widgets/home_feed_overlay.dart';
|
||||
|
||||
const _drama = Drama(
|
||||
id: 'info-hierarchy',
|
||||
title: 'The Last Promise',
|
||||
synopsis:
|
||||
'A hidden promise brings two families together after years apart. '
|
||||
'Every secret leads them closer to an unexpected truth.',
|
||||
titleLocales: {'id': 'Janji Terakhir'},
|
||||
synopsisLocales: {
|
||||
'id':
|
||||
'Sebuah janji tersembunyi mempertemukan kembali dua keluarga yang '
|
||||
'telah lama terpisah. Setiap rahasia membawa mereka semakin dekat '
|
||||
'pada kebenaran yang tidak pernah mereka bayangkan sebelumnya.',
|
||||
},
|
||||
posterUrl: '',
|
||||
genre: 'Romance',
|
||||
category: 'Urban',
|
||||
types: [
|
||||
{'zh': '甜宠', 'en': 'Romance', 'id': 'Romantis'},
|
||||
{'zh': '都市', 'en': 'Urban', 'id': 'Perkotaan'},
|
||||
],
|
||||
viewCount: 120000,
|
||||
totalEpisodes: 24,
|
||||
);
|
||||
const _episode = Episode(id: 'info-2', number: 2, title: '', isFree: true);
|
||||
const _gap = 2.0;
|
||||
const _metadataGap = 8.0;
|
||||
const _epsilon = 0.1;
|
||||
const _wideWidth = 500.0;
|
||||
const _maximumTags = 2;
|
||||
const _shortTag = 'A';
|
||||
const _secondTag = 'B';
|
||||
const _extraTag = 'C';
|
||||
const _motionSample = Duration(milliseconds: 80);
|
||||
const _watchRadius = 6.0;
|
||||
|
||||
Future<void> _pump(
|
||||
WidgetTester tester, {
|
||||
String language = 'en',
|
||||
double width = 390,
|
||||
double scale = 1,
|
||||
Drama drama = _drama,
|
||||
}) async {
|
||||
tester.view.physicalSize = Size(width, 844);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: AppTheme.dark(),
|
||||
locale: Locale(language),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Builder(
|
||||
builder: (context) => MediaQuery(
|
||||
data: MediaQuery.of(
|
||||
context,
|
||||
).copyWith(textScaler: TextScaler.linear(scale)),
|
||||
child: Scaffold(
|
||||
body: HomeFeedOverlay(
|
||||
l10n: AppLocalizations.of(context)!,
|
||||
drama: drama,
|
||||
episode: _episode,
|
||||
controller: null,
|
||||
locked: false,
|
||||
liked: false,
|
||||
saved: false,
|
||||
visible: true,
|
||||
boosting: false,
|
||||
onLike: () {},
|
||||
onSave: () {},
|
||||
onComments: () {},
|
||||
onShare: () {},
|
||||
onDetail: () {},
|
||||
onOpenFullPlayer: () {},
|
||||
onOpenSearch: () {},
|
||||
onTogglePlay: () {},
|
||||
onBoostStart: () {},
|
||||
onBoostEnd: () {},
|
||||
onMore: () {},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Iterable<TextStyle> _spanStyles(InlineSpan? span) sync* {
|
||||
if (span is! TextSpan) return;
|
||||
if (span.style != null) yield span.style!;
|
||||
for (final child in span.children ?? const <InlineSpan>[]) {
|
||||
yield* _spanStyles(child);
|
||||
}
|
||||
}
|
||||
|
||||
void _expectNoShadows(WidgetTester tester) {
|
||||
final panel = find.byKey(const Key('home-feed-info-panel'));
|
||||
for (final text in tester.widgetList<Text>(
|
||||
find.descendant(of: panel, matching: find.byType(Text)),
|
||||
)) {
|
||||
final styles = [
|
||||
if (text.style != null) text.style!,
|
||||
..._spanStyles(text.textSpan),
|
||||
];
|
||||
for (final style in styles) {
|
||||
expect(style.shadows ?? const <Shadow>[], isEmpty);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _expectWatchGap(WidgetTester tester) {
|
||||
final panel = tester.getRect(find.byKey(const Key('home-feed-info-panel')));
|
||||
final watch = tester.getRect(find.byKey(const Key('home-feed-full-series')));
|
||||
expect(watch.top - panel.bottom, closeTo(_gap, _epsilon));
|
||||
}
|
||||
|
||||
void _expectSynopsisTopGap(WidgetTester tester, Finder synopsis) {
|
||||
final metadata = tester.getRect(find.byKey(const Key('home-feed-metadata')));
|
||||
expect(
|
||||
tester.getRect(synopsis).top - metadata.bottom,
|
||||
closeTo(_gap, _epsilon),
|
||||
);
|
||||
}
|
||||
|
||||
void _expectExpandedSynopsis(WidgetTester tester) {
|
||||
final expanded = find.byKey(const Key('home-feed-synopsis-expanded'));
|
||||
expect(
|
||||
tester.widget<Text>(expanded).textSpan?.style?.color,
|
||||
AppColors.overlaySecondary,
|
||||
);
|
||||
_expectNoShadows(tester);
|
||||
_expectWatchGap(tester);
|
||||
_expectSynopsisTopGap(tester, expanded);
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('should_render_complete_chinese_toggle_when_theme_font_applies', (
|
||||
tester,
|
||||
) async {
|
||||
await _pump(tester, language: 'zh', width: 320, scale: 1.3);
|
||||
final toggle = find.byKey(const Key('home-feed-synopsis-more'));
|
||||
final rect = tester.getRect(toggle);
|
||||
for (final label in ['展开', '收起']) {
|
||||
final rich = find.descendant(of: toggle, matching: find.byType(RichText));
|
||||
final paragraph = tester.widget<RichText>(rich);
|
||||
expect(paragraph.text.toPlainText(), label);
|
||||
final painter = TextPainter(
|
||||
text: paragraph.text,
|
||||
textDirection: paragraph.textDirection ?? TextDirection.ltr,
|
||||
textScaler: paragraph.textScaler,
|
||||
locale: paragraph.locale,
|
||||
maxLines: 1,
|
||||
)..layout();
|
||||
expect(
|
||||
tester.getSize(rich).width + _epsilon,
|
||||
greaterThanOrEqualTo(painter.width),
|
||||
);
|
||||
expect(
|
||||
tester.getSize(rich).height + _epsilon,
|
||||
greaterThanOrEqualTo(painter.height),
|
||||
);
|
||||
painter.dispose();
|
||||
expect(tester.getRect(toggle), rect);
|
||||
await tester.tap(toggle);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('should_use_neutral_text_when_rendering_type_tag', (
|
||||
tester,
|
||||
) async {
|
||||
await _pump(tester);
|
||||
final tag = tester.widget<Text>(
|
||||
find.descendant(
|
||||
of: find.byKey(const Key('home-feed-tag-Romance')),
|
||||
matching: find.byType(Text),
|
||||
),
|
||||
);
|
||||
expect(tag.style?.color, AppColors.overlaySecondary);
|
||||
});
|
||||
|
||||
testWidgets('should_skip_motion_when_system_disables_animations', (
|
||||
tester,
|
||||
) async {
|
||||
tester.platformDispatcher.accessibilityFeaturesTestValue =
|
||||
const FakeAccessibilityFeatures(disableAnimations: true);
|
||||
addTearDown(tester.platformDispatcher.clearAccessibilityFeaturesTestValue);
|
||||
await _pump(tester);
|
||||
expect(find.byKey(const Key('home-feed-synopsis-size')), findsNothing);
|
||||
await tester.tap(find.byKey(const Key('home-feed-synopsis-more')));
|
||||
await tester.pumpAndSettle();
|
||||
_expectExpandedSynopsis(tester);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('should_reverse_cleanly_when_toggling_during_motion', (
|
||||
tester,
|
||||
) async {
|
||||
await _pump(tester);
|
||||
final panel = find.byKey(const Key('home-feed-info-panel'));
|
||||
final height = tester.getSize(panel).height;
|
||||
final toggle = find.byKey(const Key('home-feed-synopsis-more'));
|
||||
final position = tester.getRect(toggle);
|
||||
for (final _ in [false, true]) {
|
||||
await tester.tap(toggle);
|
||||
await tester.pump();
|
||||
await tester.pump(_motionSample);
|
||||
expect(tester.getRect(toggle), position);
|
||||
}
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const Key('home-feed-synopsis-expanded')), findsNothing);
|
||||
expect(tester.getSize(panel).height, closeTo(height, _epsilon));
|
||||
_expectWatchGap(tester);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('should_omit_toggle_when_synopsis_fits_two_lines', (
|
||||
tester,
|
||||
) async {
|
||||
await _pump(
|
||||
tester,
|
||||
drama: const Drama(
|
||||
id: 'short-synopsis',
|
||||
title: 'Short',
|
||||
synopsis: 'A short story.',
|
||||
posterUrl: '',
|
||||
),
|
||||
);
|
||||
expect(find.byKey(const Key('home-feed-synopsis')), findsOneWidget);
|
||||
expect(find.byKey(const Key('home-feed-synopsis-more')), findsNothing);
|
||||
_expectWatchGap(tester);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('should_remove_tag_surfaces_when_rendering_types', (
|
||||
tester,
|
||||
) async {
|
||||
await _pump(tester);
|
||||
for (final key in ['home-feed-tag-Urban', 'home-feed-tag-Romance']) {
|
||||
expect(find.byKey(Key(key)), findsOneWidget);
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byKey(Key(key)),
|
||||
matching: find.byType(DecoratedBox),
|
||||
),
|
||||
findsNothing,
|
||||
);
|
||||
}
|
||||
_expectNoShadows(tester);
|
||||
});
|
||||
|
||||
testWidgets('should_use_small_radius_and_brand_gradient_when_watching_all', (
|
||||
tester,
|
||||
) async {
|
||||
await _pump(tester);
|
||||
final background = find.byKey(const Key('home-feed-full-series-bg'));
|
||||
final decorated = tester.widget<DecoratedBox>(
|
||||
find.byKey(const Key('home-feed-full-series-gradient')),
|
||||
);
|
||||
final decoration = decorated.decoration as BoxDecoration;
|
||||
expect((decoration.gradient! as LinearGradient).colors, [
|
||||
AppColors.mineStatShade,
|
||||
AppColors.brandRed,
|
||||
AppColors.vipButtonEnd,
|
||||
]);
|
||||
final gradient = decoration.gradient! as LinearGradient;
|
||||
expect(gradient.begin, Alignment.centerLeft);
|
||||
expect(gradient.end, Alignment.centerRight);
|
||||
expect(tester.widget<Text>(find.text('Watch all')).style?.fontSize, 16);
|
||||
expect(
|
||||
tester.widget<Text>(find.text('Watch all')).style?.fontWeight,
|
||||
FontWeight.w400,
|
||||
);
|
||||
final clip = tester.widget<ClipRRect>(
|
||||
find.ancestor(of: background, matching: find.byType(ClipRRect)).first,
|
||||
);
|
||||
expect(clip.borderRadius, BorderRadius.circular(_watchRadius));
|
||||
});
|
||||
|
||||
for (final hot in [false, true]) {
|
||||
testWidgets(
|
||||
'should_limit_tags_and_follow_them_with_episode_when_hot_is_$hot',
|
||||
(tester) async {
|
||||
await _pump(
|
||||
tester,
|
||||
width: _wideWidth,
|
||||
drama: Drama(
|
||||
id: 'compact-tags',
|
||||
title: 'Compact',
|
||||
synopsis: '',
|
||||
posterUrl: '',
|
||||
types: const [
|
||||
{'en': _shortTag},
|
||||
{'en': _secondTag},
|
||||
{'en': _extraTag},
|
||||
],
|
||||
isHot: hot,
|
||||
totalEpisodes: _drama.totalEpisodes,
|
||||
),
|
||||
);
|
||||
final strip = find.byKey(const Key('home-feed-tags'));
|
||||
final row = tester.widget<Row>(
|
||||
find.descendant(of: strip, matching: find.byType(Row)),
|
||||
);
|
||||
expect(row.children.length, _maximumTags * 2 - 1);
|
||||
final lastTag = find.byKey(const Key('home-feed-tag-$_secondTag'));
|
||||
expect(lastTag, findsOneWidget);
|
||||
expect(find.byKey(const Key('home-feed-tag-$_extraTag')), findsNothing);
|
||||
expect(find.byKey(const Key('home-feed-hot-badge')), findsNothing);
|
||||
final episode = find.byKey(const Key('home-feed-episode-meta'));
|
||||
expect(
|
||||
tester.getRect(episode).left - tester.getRect(lastTag).right,
|
||||
closeTo(_metadataGap, _epsilon),
|
||||
);
|
||||
_expectNoShadows(tester);
|
||||
expect(tester.takeException(), isNull);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets(
|
||||
'should_remove_text_shadows_and_quieten_metadata_when_collapsed',
|
||||
(tester) async {
|
||||
await _pump(tester);
|
||||
_expectNoShadows(tester);
|
||||
final title = tester.widget<Text>(
|
||||
find.byKey(const Key('home-feed-series-title')),
|
||||
);
|
||||
expect(title.style?.fontWeight, FontWeight.w400);
|
||||
expect(title.style?.fontSize, 18);
|
||||
expect(title.style?.color, AppColors.textPrimary);
|
||||
final synopsis = tester.widget<Text>(
|
||||
find.byKey(const Key('home-feed-synopsis')),
|
||||
);
|
||||
expect(synopsis.style?.color, AppColors.overlaySecondary);
|
||||
expect(synopsis.style?.fontSize, 15);
|
||||
expect(synopsis.maxLines, 2);
|
||||
final episode = tester.widget<Text>(
|
||||
find.byKey(const Key('home-feed-episode-meta')),
|
||||
);
|
||||
expect(episode.style?.color, AppColors.textPrimary);
|
||||
expect(episode.style?.fontSize, 14);
|
||||
expect(episode.data, 'Episode 2 / 24');
|
||||
final tag = tester.widget<Text>(find.text('Romance'));
|
||||
expect(tag.style?.color, AppColors.overlaySecondary);
|
||||
expect(tag.style?.color, synopsis.style?.color);
|
||||
expect(tag.style?.fontSize, 13);
|
||||
final hotSearch = tester.widget<Text>(
|
||||
find.descendant(
|
||||
of: find.byKey(const Key('home-feed-hot-search-pill')),
|
||||
matching: find.byType(Text),
|
||||
),
|
||||
);
|
||||
expect(hotSearch.style?.fontSize, 13);
|
||||
_expectWatchGap(tester);
|
||||
final titleRect = tester.getRect(
|
||||
find.byKey(const Key('home-feed-series-name')),
|
||||
);
|
||||
final metadata = tester.getRect(
|
||||
find.byKey(const Key('home-feed-metadata')),
|
||||
);
|
||||
final tags = tester.getRect(find.byKey(const Key('home-feed-tags')));
|
||||
final episodeRect = tester.getRect(
|
||||
find.byKey(const Key('home-feed-episode-meta')),
|
||||
);
|
||||
final description = tester.getRect(
|
||||
find.byKey(const Key('home-feed-synopsis')),
|
||||
);
|
||||
expect(metadata.top - titleRect.bottom, closeTo(_gap, _epsilon));
|
||||
expect(tags.center.dy, closeTo(episodeRect.center.dy, _epsilon));
|
||||
expect(description.top - metadata.bottom, closeTo(_gap, _epsilon));
|
||||
final panel = tester.getRect(
|
||||
find.byKey(const Key('home-feed-info-panel')),
|
||||
);
|
||||
final watch = tester.getRect(
|
||||
find.byKey(const Key('home-feed-full-series')),
|
||||
);
|
||||
final rail = tester.getRect(
|
||||
find.byKey(const Key('home-feed-action-rail')),
|
||||
);
|
||||
expect(watch.left, panel.left);
|
||||
expect(watch.right, panel.right);
|
||||
expect(watch.right, lessThan(rail.left));
|
||||
expect(
|
||||
tester.getCenter(find.text('Watch all')).dx,
|
||||
closeTo(watch.center.dx, _epsilon),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
for (final language in ['zh', 'en', 'id']) {
|
||||
testWidgets('should_keep_clear_gap_when_expanded_in_$language', (
|
||||
tester,
|
||||
) async {
|
||||
await _pump(tester, language: language, width: 320, scale: 1.3);
|
||||
final toggle = find.byKey(const Key('home-feed-synopsis-more'));
|
||||
final toggleRect = tester.getRect(toggle);
|
||||
final panel = find.byKey(const Key('home-feed-info-panel'));
|
||||
final collapsedHeight = tester.getSize(panel).height;
|
||||
final label = tester.widget<Text>(
|
||||
find.descendant(of: toggle, matching: find.byType(Text)),
|
||||
);
|
||||
expect(label.style?.fontWeight, FontWeight.w400);
|
||||
expect(label.style?.fontSize, 15);
|
||||
_expectWatchGap(tester);
|
||||
_expectSynopsisTopGap(
|
||||
tester,
|
||||
find.byKey(const Key('home-feed-synopsis-layout')),
|
||||
);
|
||||
await tester.tap(toggle);
|
||||
await tester.pump();
|
||||
await tester.pump(_motionSample);
|
||||
final growingHeight = tester.getSize(panel).height;
|
||||
expect(tester.getRect(toggle), toggleRect);
|
||||
await tester.pumpAndSettle();
|
||||
final expandedHeight = tester.getSize(panel).height;
|
||||
expect(growingHeight, greaterThan(collapsedHeight));
|
||||
expect(growingHeight, lessThan(expandedHeight));
|
||||
_expectExpandedSynopsis(tester);
|
||||
expect(tester.getRect(toggle), toggleRect);
|
||||
await tester.tap(toggle);
|
||||
await tester.pump();
|
||||
await tester.pump(_motionSample);
|
||||
final shrinkingHeight = tester.getSize(panel).height;
|
||||
expect(shrinkingHeight, greaterThan(collapsedHeight));
|
||||
expect(shrinkingHeight, lessThan(expandedHeight));
|
||||
expect(tester.getRect(toggle), toggleRect);
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.getSize(panel).height, closeTo(collapsedHeight, _epsilon));
|
||||
_expectWatchGap(tester);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
testWidgets('should_keep_episode_without_empty_tags_or_unknown_total', (
|
||||
tester,
|
||||
) async {
|
||||
await _pump(
|
||||
tester,
|
||||
drama: const Drama(
|
||||
id: 'minimal',
|
||||
title: 'Minimal',
|
||||
synopsis: '',
|
||||
posterUrl: '',
|
||||
),
|
||||
);
|
||||
final episode = tester.widget<Text>(
|
||||
find.byKey(const Key('home-feed-episode-meta')),
|
||||
);
|
||||
expect(episode.data, 'EP2');
|
||||
expect(find.byKey(const Key('home-feed-tags')), findsNothing);
|
||||
expect(find.byKey(const Key('home-feed-synopsis')), findsNothing);
|
||||
_expectWatchGap(tester);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
// Author: fruit.com.cn
|
||||
// 职责:验证首页标签仅取 types 前两项,以及多语言/字号下与集数的文字基线对齐。
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fruitshorts/core/data/models/drama.dart';
|
||||
import 'package:fruitshorts/core/theme/app_theme.dart';
|
||||
import 'package:fruitshorts/l10n/app_localizations.dart';
|
||||
import 'package:fruitshorts/pages/player/widgets/home_feed_recommend_panel.dart';
|
||||
|
||||
const _types = [
|
||||
{'zh': '都市', 'en': 'Urban', 'id': 'Perkotaan'},
|
||||
{'zh': '甜宠', 'en': 'Romance', 'id': 'Romantis'},
|
||||
{'zh': '复仇', 'en': 'Revenge', 'id': 'Balas Dendam'},
|
||||
];
|
||||
const _typedDrama = Drama(
|
||||
id: 'types-alignment',
|
||||
title: 'Types',
|
||||
synopsis: '',
|
||||
posterUrl: '',
|
||||
types: _types,
|
||||
genre: 'Legacy genre',
|
||||
category: 'Legacy category',
|
||||
isHot: true,
|
||||
totalEpisodes: 24,
|
||||
);
|
||||
const _episode = Episode(id: 'types-2', number: 2, title: '', isFree: true);
|
||||
const _tolerance = 0.1;
|
||||
const _narrowWidth = 320.0;
|
||||
const _regularWidth = 390.0;
|
||||
const _largeScale = 1.3;
|
||||
const _tagCount = 2;
|
||||
const _longType = 'A very long urban romance type description';
|
||||
const _scrollDelta = Offset(-180, 0);
|
||||
|
||||
Future<void> _pump(
|
||||
WidgetTester tester, {
|
||||
Drama drama = _typedDrama,
|
||||
String language = 'en',
|
||||
double scale = 1,
|
||||
double width = _narrowWidth,
|
||||
}) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: AppTheme.dark(),
|
||||
locale: Locale(language),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Builder(
|
||||
builder: (context) => MediaQuery(
|
||||
data: MediaQuery.of(
|
||||
context,
|
||||
).copyWith(textScaler: TextScaler.linear(scale)),
|
||||
child: Scaffold(
|
||||
body: Align(
|
||||
alignment: Alignment.bottomLeft,
|
||||
child: SizedBox(
|
||||
width: width,
|
||||
child: HomeFeedInfoPanel(
|
||||
drama: drama,
|
||||
episode: _episode,
|
||||
onDetail: () {},
|
||||
onOpenFullPlayer: () {},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
double _baseline(WidgetTester tester, Finder text) {
|
||||
final rich = find.descendant(of: text, matching: find.byType(RichText));
|
||||
final paragraph = tester.renderObject<RenderParagraph>(rich);
|
||||
final widget = tester.widget<RichText>(rich);
|
||||
final painter =
|
||||
TextPainter(
|
||||
text: widget.text,
|
||||
textDirection: widget.textDirection ?? TextDirection.ltr,
|
||||
textScaler: widget.textScaler,
|
||||
strutStyle: widget.strutStyle,
|
||||
textWidthBasis: widget.textWidthBasis,
|
||||
textHeightBehavior: widget.textHeightBehavior,
|
||||
locale: widget.locale,
|
||||
maxLines: widget.maxLines,
|
||||
)..layout(
|
||||
minWidth: paragraph.constraints.minWidth,
|
||||
maxWidth: paragraph.constraints.maxWidth,
|
||||
);
|
||||
final distance = painter.computeLineMetrics().first.baseline;
|
||||
painter.dispose();
|
||||
return tester.getTopLeft(rich).dy + distance;
|
||||
}
|
||||
|
||||
Finder _tag(String label) => find.byKey(Key('home-feed-tag-$label'));
|
||||
|
||||
void main() {
|
||||
testWidgets('should_use_first_two_types_when_drama_is_hot', (tester) async {
|
||||
await _pump(tester);
|
||||
final first = _tag('Urban');
|
||||
final second = _tag('Romance');
|
||||
expect(first, findsOneWidget);
|
||||
expect(second, findsOneWidget);
|
||||
expect(_tag('Revenge'), findsNothing);
|
||||
expect(find.byKey(const Key('home-feed-hot-badge')), findsNothing);
|
||||
expect(_tag(_typedDrama.genre), findsNothing);
|
||||
expect(_tag(_typedDrama.category), findsNothing);
|
||||
expect(tester.getRect(first).left, lessThan(tester.getRect(second).left));
|
||||
});
|
||||
|
||||
testWidgets('should_omit_legacy_fallback_when_types_are_empty', (
|
||||
tester,
|
||||
) async {
|
||||
await _pump(
|
||||
tester,
|
||||
drama: const Drama(
|
||||
id: 'empty-types',
|
||||
title: '',
|
||||
synopsis: '',
|
||||
posterUrl: '',
|
||||
genre: 'Legacy genre',
|
||||
category: 'Legacy category',
|
||||
categoryLocales: {'zh': '旧分类', 'en': 'Old category'},
|
||||
isHot: true,
|
||||
),
|
||||
);
|
||||
expect(find.byKey(const Key('home-feed-tags')), findsNothing);
|
||||
expect(find.byKey(const Key('home-feed-episode-meta')), findsOneWidget);
|
||||
expect(find.byKey(const Key('home-feed-hot-badge')), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('should_keep_only_existing_type_when_types_have_one_item', (
|
||||
tester,
|
||||
) async {
|
||||
await _pump(
|
||||
tester,
|
||||
drama: const Drama(
|
||||
id: 'one-type',
|
||||
title: '',
|
||||
synopsis: '',
|
||||
posterUrl: '',
|
||||
types: [
|
||||
{'zh': '都市', 'en': 'Urban'},
|
||||
],
|
||||
genre: 'Legacy genre',
|
||||
isHot: true,
|
||||
),
|
||||
);
|
||||
expect(_tag('Urban'), findsOneWidget);
|
||||
expect(find.byKey(const Key('home-feed-hot-badge')), findsNothing);
|
||||
expect(_tag('Legacy genre'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('should_not_fill_from_third_type_when_first_type_is_empty', (
|
||||
tester,
|
||||
) async {
|
||||
await _pump(
|
||||
tester,
|
||||
drama: const Drama(
|
||||
id: 'blank-type',
|
||||
title: '',
|
||||
synopsis: '',
|
||||
posterUrl: '',
|
||||
types: [
|
||||
{'zh': '', 'en': ''},
|
||||
{'zh': '都市', 'en': 'Urban'},
|
||||
{'zh': '甜宠', 'en': 'Romance'},
|
||||
],
|
||||
),
|
||||
);
|
||||
expect(_tag('Urban'), findsOneWidget);
|
||||
expect(_tag('Romance'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('should_use_chinese_fallback_when_type_translation_is_absent', (
|
||||
tester,
|
||||
) async {
|
||||
await _pump(
|
||||
tester,
|
||||
language: 'id',
|
||||
drama: const Drama(
|
||||
id: 'type-fallback',
|
||||
title: '',
|
||||
synopsis: '',
|
||||
posterUrl: '',
|
||||
types: [
|
||||
{'zh': '都市', 'en': 'Urban'},
|
||||
{'zh': '甜宠', 'id': 'Romantis'},
|
||||
],
|
||||
),
|
||||
);
|
||||
expect(_tag('都市'), findsOneWidget);
|
||||
expect(_tag('Romantis'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('should_keep_episode_aligned_when_long_types_scroll', (
|
||||
tester,
|
||||
) async {
|
||||
await _pump(
|
||||
tester,
|
||||
scale: _largeScale,
|
||||
drama: const Drama(
|
||||
id: 'long-types',
|
||||
title: '',
|
||||
synopsis: '',
|
||||
posterUrl: '',
|
||||
totalEpisodes: 24,
|
||||
types: [
|
||||
{'en': _longType},
|
||||
{'en': 'Romance'},
|
||||
],
|
||||
),
|
||||
);
|
||||
final strip = find.byKey(const Key('home-feed-tags'));
|
||||
final episode = find.byKey(const Key('home-feed-episode-meta'));
|
||||
final episodeRect = tester.getRect(episode);
|
||||
final tagRect = tester.getRect(_tag(_longType));
|
||||
expect(tagRect.right, greaterThan(tester.getRect(strip).right));
|
||||
await tester.drag(strip, _scrollDelta);
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.getRect(_tag(_longType)).left, lessThan(tagRect.left));
|
||||
expect(tester.getRect(episode), episodeRect);
|
||||
expect(
|
||||
_baseline(tester, _tag(_longType)),
|
||||
closeTo(_baseline(tester, episode), _tolerance),
|
||||
);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
for (final language in ['zh', 'en', 'id']) {
|
||||
for (final scale in [1.0, _largeScale]) {
|
||||
for (final width in [_narrowWidth, _regularWidth]) {
|
||||
testWidgets(
|
||||
'should_align_type_baselines_when_${language}_${scale}_$width',
|
||||
(tester) async {
|
||||
await _pump(tester, language: language, scale: scale, width: width);
|
||||
final episode = find.byKey(const Key('home-feed-episode-meta'));
|
||||
final expected = _baseline(tester, episode);
|
||||
for (final type in _types.take(_tagCount)) {
|
||||
expect(
|
||||
_baseline(tester, _tag(type[language]!)),
|
||||
closeTo(expected, _tolerance),
|
||||
);
|
||||
}
|
||||
expect(tester.takeException(), isNull);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// Author: fruit.com.cn
|
||||
// 职责:检查观看全集按钮实际绘制像素,防止渐变只配置而不可见。
|
||||
import 'dart:typed_data';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fruitshorts/core/data/models/drama.dart';
|
||||
import 'package:fruitshorts/l10n/app_localizations.dart';
|
||||
import 'package:fruitshorts/pages/player/widgets/home_feed_recommend_panel.dart';
|
||||
|
||||
const _boundaryKey = Key('watch-gradient-boundary');
|
||||
const _width = 280.0;
|
||||
const _sampleInset = 16;
|
||||
const _sampleY = 4;
|
||||
const _rgbaChannels = 4;
|
||||
const _minimumRedDelta = 50;
|
||||
const _drama = Drama(
|
||||
id: 'gradient',
|
||||
title: 'Gradient',
|
||||
synopsis: '',
|
||||
posterUrl: '',
|
||||
);
|
||||
|
||||
Future<void> _pumpButton(WidgetTester tester) => tester.pumpWidget(
|
||||
MaterialApp(
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Scaffold(
|
||||
body: Center(
|
||||
child: RepaintBoundary(
|
||||
key: _boundaryKey,
|
||||
child: SizedBox(
|
||||
width: _width,
|
||||
child: HomeFeedWatchAllBar(drama: _drama, onTap: () {}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Color _sample(ByteData bytes, int width, int x) {
|
||||
final offset = (_sampleY * width + x) * _rgbaChannels;
|
||||
return Color.fromARGB(
|
||||
bytes.getUint8(offset + 3),
|
||||
bytes.getUint8(offset),
|
||||
bytes.getUint8(offset + 1),
|
||||
bytes.getUint8(offset + 2),
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('should_paint_visible_red_gradient_instead_of_flat_fill', (
|
||||
tester,
|
||||
) async {
|
||||
await _pumpButton(tester);
|
||||
final boundary = tester.renderObject<RenderRepaintBoundary>(
|
||||
find.byKey(_boundaryKey),
|
||||
);
|
||||
await tester.runAsync(() async {
|
||||
final image = await boundary.toImage();
|
||||
final bytes = (await image.toByteData(
|
||||
format: ui.ImageByteFormat.rawRgba,
|
||||
))!;
|
||||
final left = _sample(bytes, image.width, _sampleInset);
|
||||
final middle = _sample(bytes, image.width, image.width ~/ 2);
|
||||
final right = _sample(bytes, image.width, image.width - _sampleInset);
|
||||
image.dispose();
|
||||
expect(left.a, 1);
|
||||
expect(right.a, 1);
|
||||
expect((right.r - left.r) * 255, greaterThan(_minimumRedDelta));
|
||||
expect(left.g, lessThan(middle.g));
|
||||
expect(middle.g, lessThan(right.g));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// Author: fruit.com.cn
|
||||
// 职责:验证共用简介的短文案、展开声明、内容更新与减少动画行为。
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fruitshorts/core/theme/app_theme.dart';
|
||||
import 'package:fruitshorts/l10n/app_localizations.dart';
|
||||
import 'package:fruitshorts/pages/player/widgets/expandable_synopsis.dart';
|
||||
|
||||
const _width = 300.0;
|
||||
const _shortCopy = 'A short story.';
|
||||
const _longCopy =
|
||||
'A hidden promise brings two families together after years apart. '
|
||||
'Every secret leads them closer to an unexpected truth. '
|
||||
'Their journey through the city changes everything they once believed.';
|
||||
const _toggle = Key('player-synopsis-more');
|
||||
const _expanded = Key('player-synopsis-expanded');
|
||||
const _collapsed = Key('player-synopsis');
|
||||
const _footer = Text('Author declaration', key: Key('test-author-note'));
|
||||
const _motionSample = Duration(milliseconds: 80);
|
||||
|
||||
Future<void> _pump(
|
||||
WidgetTester tester, {
|
||||
String synopsis = _longCopy,
|
||||
bool disableAnimations = false,
|
||||
}) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: AppTheme.dark(),
|
||||
locale: const Locale('en'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Builder(
|
||||
builder: (context) => MediaQuery(
|
||||
data: MediaQuery.of(
|
||||
context,
|
||||
).copyWith(disableAnimations: disableAnimations),
|
||||
child: Scaffold(
|
||||
body: Align(
|
||||
alignment: Alignment.bottomLeft,
|
||||
child: SizedBox(
|
||||
width: _width,
|
||||
child: ExpandableSynopsis(
|
||||
synopsis: synopsis,
|
||||
keyPrefix: 'player',
|
||||
expandedFooter: _footer,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('should_hide_toggle_when_synopsis_fits_two_lines', (
|
||||
tester,
|
||||
) async {
|
||||
await _pump(tester, synopsis: _shortCopy);
|
||||
expect(find.byKey(_toggle), findsNothing);
|
||||
expect(tester.widget<Text>(find.byKey(_collapsed)).maxLines, 2);
|
||||
expect(find.byKey(_footer.key!), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('should_render_no_copy_when_synopsis_is_empty', (tester) async {
|
||||
await _pump(tester, synopsis: '');
|
||||
expect(find.byKey(_collapsed), findsNothing);
|
||||
expect(find.byKey(_toggle), findsNothing);
|
||||
expect(find.byKey(_footer.key!), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('should_only_show_author_note_when_synopsis_is_expanded', (
|
||||
tester,
|
||||
) async {
|
||||
await _pump(tester);
|
||||
final toggle = find.byKey(_toggle);
|
||||
final toggleRect = tester.getRect(toggle);
|
||||
expect(find.byKey(_footer.key!), findsNothing);
|
||||
await tester.tap(toggle);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(_expanded), findsOneWidget);
|
||||
expect(find.byKey(_footer.key!), findsOneWidget);
|
||||
expect(tester.getRect(toggle), toggleRect);
|
||||
await tester.tap(toggle);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(_collapsed), findsOneWidget);
|
||||
expect(find.byKey(_footer.key!), findsNothing);
|
||||
expect(tester.getRect(toggle), toggleRect);
|
||||
});
|
||||
|
||||
testWidgets('should_collapse_again_when_synopsis_changes', (tester) async {
|
||||
await _pump(tester);
|
||||
await tester.tap(find.byKey(_toggle));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(_expanded), findsOneWidget);
|
||||
await _pump(tester, synopsis: '$_longCopy $_shortCopy');
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(_expanded), findsNothing);
|
||||
expect(find.byKey(_collapsed), findsOneWidget);
|
||||
expect(find.byKey(_footer.key!), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('should_toggle_immediately_when_animations_are_disabled', (
|
||||
tester,
|
||||
) async {
|
||||
await _pump(tester, disableAnimations: true);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const Key('player-synopsis-size')), findsNothing);
|
||||
await tester.tap(find.byKey(_toggle));
|
||||
await tester.pump();
|
||||
expect(find.byKey(_expanded), findsOneWidget);
|
||||
expect(find.byKey(_footer.key!), findsOneWidget);
|
||||
final expandedRect = tester.getRect(find.byKey(_expanded));
|
||||
await tester.pump(_motionSample);
|
||||
expect(tester.getRect(find.byKey(_expanded)), expandedRect);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
// Author: fruit.com.cn
|
||||
// 职责:验证真实主题及素材在同步状态刷新和保留状态重组后的点击绘制。
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fruitshorts/core/theme/app_colors.dart';
|
||||
import 'package:fruitshorts/core/theme/app_theme.dart';
|
||||
import 'package:fruitshorts/pages/player/widgets/interaction_rail.dart';
|
||||
import 'package:fruitshorts/pages/player/widgets/interaction_rail_burst.dart';
|
||||
|
||||
const _itemKey = Key('asset-like');
|
||||
const _burstKey = Key('rail-tap-burst');
|
||||
const _buttonSize = 48.0;
|
||||
const _iconSize = 36.0;
|
||||
const _motionFrame = Duration(milliseconds: 130);
|
||||
const _asset = 'assets/icon/rail/rail_like.png';
|
||||
const _epsilon = 0.01;
|
||||
const _rayLimit = 26.4;
|
||||
const _rayLengthLimit = 3.5;
|
||||
const _burstPhases = [0.2, 0.5, 0.8];
|
||||
|
||||
Finder get _button => find.descendant(
|
||||
of: find.byKey(_itemKey),
|
||||
matching: find.byType(IconButton),
|
||||
);
|
||||
|
||||
Finder get _image =>
|
||||
find.descendant(of: find.byKey(_itemKey), matching: find.byType(Image));
|
||||
|
||||
double _paintedWidth(WidgetTester tester) {
|
||||
final icon = tester.renderObject<RenderBox>(_image);
|
||||
final rail = tester.renderObject<RenderBox>(find.byKey(_itemKey));
|
||||
return MatrixUtils.transformRect(
|
||||
icon.getTransformTo(rail),
|
||||
Offset.zero & icon.size,
|
||||
).width;
|
||||
}
|
||||
|
||||
Future<void> _pumpAssetRail(
|
||||
WidgetTester tester,
|
||||
ValueNotifier<bool> selected,
|
||||
) => tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: AppTheme.dark(),
|
||||
home: Scaffold(
|
||||
body: Center(
|
||||
child: ValueListenableBuilder<bool>(
|
||||
valueListenable: selected,
|
||||
builder: (context, value, _) => InteractionRail(
|
||||
buttonSize: _buttonSize,
|
||||
iconSize: _iconSize,
|
||||
items: [
|
||||
InteractionRailItem(
|
||||
key: _itemKey,
|
||||
label: 'Like',
|
||||
asset: _asset,
|
||||
selected: value,
|
||||
selectedColor: AppColors.brandRed,
|
||||
animateTap: true,
|
||||
onTap: () => selected.value = !selected.value,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
void main() {
|
||||
testWidgets('should_not_show_ink_or_shadow_when_button_is_pressed', (
|
||||
tester,
|
||||
) async {
|
||||
final selected = ValueNotifier(false);
|
||||
addTearDown(selected.dispose);
|
||||
await _pumpAssetRail(tester, selected);
|
||||
final button = tester.widget<IconButton>(_button);
|
||||
const pressed = {WidgetState.pressed};
|
||||
expect(button.style?.overlayColor?.resolve(pressed), AppColors.transparent);
|
||||
expect(button.style?.shadowColor?.resolve(pressed), AppColors.transparent);
|
||||
expect(button.style?.splashFactory, NoSplash.splashFactory);
|
||||
expect(button.style?.elevation?.resolve(pressed), 0);
|
||||
});
|
||||
|
||||
for (final contracting in [false, true]) {
|
||||
testWidgets('should_keep_rays_compact_when_contracting_$contracting', (
|
||||
tester,
|
||||
) async {
|
||||
for (final progress in _burstPhases) {
|
||||
await tester.pumpWidget(
|
||||
Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: Center(
|
||||
child: SizedBox.square(
|
||||
dimension: _buttonSize,
|
||||
child: RailTapBurst(
|
||||
progress: progress,
|
||||
color: AppColors.brandRed,
|
||||
contracting: contracting,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
final paint = tester.renderObject(find.byType(CustomPaint));
|
||||
expect(paint, paintsExactlyCountTimes(#drawCircle, 0));
|
||||
expect(paint, paintsExactlyCountTimes(#drawLine, 8));
|
||||
expect(paint, paints..everything(_isCompactRay));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
testWidgets('should_paint_feedback_at_button_size_when_asset_is_tapped', (
|
||||
tester,
|
||||
) async {
|
||||
final selected = ValueNotifier(false);
|
||||
addTearDown(selected.dispose);
|
||||
await _pumpAssetRail(tester, selected);
|
||||
final touchRect = tester.getRect(_button);
|
||||
await tester.tap(_button);
|
||||
await tester.pump();
|
||||
await tester.pump(_motionFrame);
|
||||
expect(selected.value, isTrue);
|
||||
expect(_paintedWidth(tester), greaterThan(_iconSize));
|
||||
expect(
|
||||
tester.getSize(find.byKey(_burstKey)),
|
||||
const Size.square(_buttonSize),
|
||||
);
|
||||
expect(tester.getRect(_button), touchRect);
|
||||
await tester.pumpAndSettle();
|
||||
expect(_paintedWidth(tester), closeTo(_iconSize, _epsilon));
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'should_paint_opposite_directions_after_preserved_state_reassembly',
|
||||
(tester) async {
|
||||
final selected = ValueNotifier(false);
|
||||
addTearDown(selected.dispose);
|
||||
await _pumpAssetRail(tester, selected);
|
||||
unawaited(tester.binding.reassembleApplication());
|
||||
await tester.pump();
|
||||
for (final cancelling in [false, true]) {
|
||||
await tester.tap(_button);
|
||||
await tester.pump();
|
||||
await tester.pump(_motionFrame);
|
||||
expect(
|
||||
_paintedWidth(tester),
|
||||
cancelling ? lessThan(_iconSize) : greaterThan(_iconSize),
|
||||
);
|
||||
final feedback = tester.widget<RailTapBurst>(find.byKey(_burstKey));
|
||||
expect(feedback.contracting, cancelling);
|
||||
expect(selected.value, !cancelling);
|
||||
await tester.pumpAndSettle();
|
||||
expect(_paintedWidth(tester), closeTo(_iconSize, _epsilon));
|
||||
}
|
||||
expect(tester.takeException(), isNull);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
bool _isCompactRay(Symbol method, List<dynamic> arguments) {
|
||||
if (method != #drawLine) return true;
|
||||
const center = Offset(_buttonSize / 2, _buttonSize / 2);
|
||||
final start = arguments[0] as Offset;
|
||||
final end = arguments[1] as Offset;
|
||||
return (start - center).distance <= _rayLimit &&
|
||||
(end - center).distance <= _rayLimit &&
|
||||
(end - start).distance <= _rayLengthLimit;
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
// Author: fruit.com.cn
|
||||
// 职责:验证收藏点赞图标点击回弹、固定触控区与减少动画行为。
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fruitshorts/core/theme/app_colors.dart';
|
||||
import 'package:fruitshorts/pages/player/widgets/interaction_rail.dart';
|
||||
import 'package:fruitshorts/pages/player/widgets/interaction_rail_burst.dart';
|
||||
|
||||
const _saveKey = Key('save');
|
||||
const _likeKey = Key('like');
|
||||
const _commentsKey = Key('comments');
|
||||
const _pressFrame = Duration(milliseconds: 30);
|
||||
const _popFrame = Duration(milliseconds: 100);
|
||||
const _buttonSize = 48.0;
|
||||
const _compactGap = 6.0;
|
||||
const _epsilon = 0.01;
|
||||
const _cancelledMaximum = 0.8;
|
||||
const _popMinimum = 1.22;
|
||||
const _burstKey = Key('rail-tap-burst');
|
||||
const _directionFrame = Duration(milliseconds: 60);
|
||||
const _directionFrames = 4;
|
||||
|
||||
Future<void> _pumpRail(
|
||||
WidgetTester tester, {
|
||||
required VoidCallback onTap,
|
||||
bool selected = false,
|
||||
bool reduceMotion = false,
|
||||
}) => tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: MediaQuery(
|
||||
data: MediaQueryData(disableAnimations: reduceMotion),
|
||||
child: Scaffold(
|
||||
body: Center(
|
||||
child: InteractionRail(
|
||||
itemGap: interactionRailCompactGap,
|
||||
buttonSize: _buttonSize,
|
||||
items: [
|
||||
InteractionRailItem(
|
||||
key: _saveKey,
|
||||
label: 'Save',
|
||||
icon: Icons.bookmark_outline,
|
||||
selected: selected,
|
||||
animateTap: true,
|
||||
onTap: onTap,
|
||||
),
|
||||
InteractionRailItem(
|
||||
key: _likeKey,
|
||||
label: 'Like',
|
||||
icon: Icons.favorite_outline,
|
||||
selected: selected,
|
||||
animateTap: true,
|
||||
onTap: onTap,
|
||||
),
|
||||
InteractionRailItem(
|
||||
key: _commentsKey,
|
||||
label: 'Comments',
|
||||
icon: Icons.chat_bubble_outline,
|
||||
onTap: onTap,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Finder _button(Key key) =>
|
||||
find.descendant(of: find.byKey(key), matching: find.byType(IconButton));
|
||||
|
||||
double _scale(WidgetTester tester, Key key) => tester
|
||||
.widget<Transform>(
|
||||
find
|
||||
.descendant(of: find.byKey(key), matching: find.byType(Transform))
|
||||
.first,
|
||||
)
|
||||
.transform
|
||||
.entry(0, 0);
|
||||
|
||||
void _expectBurst(WidgetTester tester, Key key, bool cancelling) {
|
||||
final burst = find.descendant(
|
||||
of: find.byKey(key),
|
||||
matching: find.byKey(_burstKey),
|
||||
);
|
||||
expect(burst, findsOneWidget);
|
||||
final feedback = tester.widget<RailTapBurst>(burst);
|
||||
expect(feedback.contracting, cancelling);
|
||||
expect(feedback.color, cancelling ? AppColors.onPrimary : AppColors.brandRed);
|
||||
final paint = find.descendant(of: burst, matching: find.byType(CustomPaint));
|
||||
expect(tester.renderObject(paint), paints..line());
|
||||
expect(tester.renderObject(paint), paintsExactlyCountTimes(#drawCircle, 0));
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('should_keep_compact_spacing_when_rail_is_idle', (tester) async {
|
||||
await _pumpRail(tester, onTap: () {});
|
||||
final save = tester.getRect(find.byKey(_saveKey));
|
||||
final like = tester.getRect(find.byKey(_likeKey));
|
||||
expect(like.top - save.bottom, closeTo(_compactGap, _epsilon));
|
||||
});
|
||||
|
||||
for (final key in [_saveKey, _likeKey]) {
|
||||
for (final selected in [false, true]) {
|
||||
testWidgets(
|
||||
'should_pop_icon_once_when_tapped_${key}_selected_$selected',
|
||||
(tester) async {
|
||||
var calls = 0;
|
||||
await _pumpRail(tester, selected: selected, onTap: () => calls++);
|
||||
final rect = tester.getRect(_button(key));
|
||||
final itemRect = tester.getRect(find.byKey(key));
|
||||
await tester.tap(_button(key));
|
||||
await tester.pump();
|
||||
await tester.pump(_pressFrame);
|
||||
expect(_scale(tester, key), selected ? lessThan(1) : greaterThan(1));
|
||||
await tester.pump(_popFrame);
|
||||
expect(
|
||||
_scale(tester, key),
|
||||
selected ? lessThan(_cancelledMaximum) : greaterThan(_popMinimum),
|
||||
);
|
||||
_expectBurst(tester, key, selected);
|
||||
expect(tester.getRect(_button(key)), rect);
|
||||
expect(tester.getRect(find.byKey(key)), itemRect);
|
||||
expect(rect.size, const Size.square(_buttonSize));
|
||||
expect(calls, 1);
|
||||
for (var frame = 0; frame < _directionFrames; frame++) {
|
||||
await tester.pump(_directionFrame);
|
||||
expect(
|
||||
_scale(tester, key),
|
||||
selected ? lessThanOrEqualTo(1) : greaterThanOrEqualTo(1),
|
||||
);
|
||||
}
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(_burstKey), findsNothing);
|
||||
expect(_scale(tester, key), closeTo(1, _epsilon));
|
||||
expect(tester.takeException(), isNull);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
testWidgets(
|
||||
'should_restart_feedback_without_duplicate_callbacks_when_rapid',
|
||||
(tester) async {
|
||||
var calls = 0;
|
||||
await _pumpRail(tester, onTap: () => calls++);
|
||||
for (final _ in [false, true]) {
|
||||
await tester.tap(_button(_likeKey));
|
||||
await tester.pump();
|
||||
await tester.pump(_popFrame);
|
||||
}
|
||||
expect(calls, 2);
|
||||
await tester.pumpAndSettle();
|
||||
expect(_scale(tester, _likeKey), closeTo(1, _epsilon));
|
||||
expect(tester.takeException(), isNull);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('should_keep_callbacks_without_motion_when_reduced', (
|
||||
tester,
|
||||
) async {
|
||||
var calls = 0;
|
||||
await _pumpRail(tester, reduceMotion: true, onTap: () => calls++);
|
||||
await tester.tap(_button(_saveKey));
|
||||
await tester.pump(_pressFrame);
|
||||
expect(_scale(tester, _saveKey), 1);
|
||||
expect(find.byKey(_burstKey), findsNothing);
|
||||
expect(calls, 1);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('should_keep_burst_intent_when_selection_updates_during_motion', (
|
||||
tester,
|
||||
) async {
|
||||
var calls = 0;
|
||||
void onTap() => calls++;
|
||||
await _pumpRail(tester, onTap: onTap);
|
||||
await tester.tap(_button(_likeKey));
|
||||
await tester.pump();
|
||||
await tester.pump(_popFrame);
|
||||
await _pumpRail(tester, selected: true, onTap: onTap);
|
||||
expect(
|
||||
tester.widget<RailTapBurst>(find.byKey(_burstKey)).contracting,
|
||||
isFalse,
|
||||
);
|
||||
await tester.tap(_button(_likeKey));
|
||||
await tester.pump();
|
||||
await tester.pump(_popFrame);
|
||||
final feedback = tester.widget<RailTapBurst>(find.byKey(_burstKey));
|
||||
expect(feedback.contracting, isTrue);
|
||||
expect(feedback.color, AppColors.onPrimary);
|
||||
expect(calls, 2);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(_burstKey), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('should_stop_burst_when_reduced_motion_changes_during_feedback', (
|
||||
tester,
|
||||
) async {
|
||||
await _pumpRail(tester, onTap: () {});
|
||||
await tester.tap(_button(_saveKey));
|
||||
await tester.pump();
|
||||
await tester.pump(_popFrame);
|
||||
expect(find.byKey(_burstKey), findsOneWidget);
|
||||
await _pumpRail(tester, reduceMotion: true, onTap: () {});
|
||||
expect(find.byKey(_burstKey), findsNothing);
|
||||
expect(_scale(tester, _saveKey), 1);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('should_not_pop_comments_when_motion_is_not_enabled', (
|
||||
tester,
|
||||
) async {
|
||||
var calls = 0;
|
||||
await _pumpRail(tester, onTap: () => calls++);
|
||||
await tester.tap(_button(_commentsKey));
|
||||
await tester.pump(_pressFrame);
|
||||
expect(_scale(tester, _commentsKey), 1);
|
||||
expect(find.byKey(_burstKey), findsNothing);
|
||||
expect(calls, 1);
|
||||
});
|
||||
|
||||
testWidgets('should_dispose_running_motion_when_rail_is_removed', (
|
||||
tester,
|
||||
) async {
|
||||
await _pumpRail(tester, onTap: () {});
|
||||
await tester.tap(_button(_likeKey));
|
||||
await tester.pump(_pressFrame);
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
@@ -1271,6 +1271,22 @@ void main() {
|
||||
expect(find.byKey(const Key('player-interaction-rail')), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('should_keep_episode_entry_regular_when_player_is_open', (
|
||||
tester,
|
||||
) async {
|
||||
await _pumpApp(tester);
|
||||
await _openSeriesPlayer(tester);
|
||||
final entry = tester.widget<Text>(
|
||||
find.descendant(
|
||||
of: find.byKey(const Key('player-episodes-pill')),
|
||||
matching: find.byType(Text),
|
||||
),
|
||||
);
|
||||
expect(entry.style?.fontWeight, FontWeight.w400);
|
||||
expect(entry.style?.fontSize, 13.5);
|
||||
expect(entry.data, contains(' · '));
|
||||
});
|
||||
|
||||
testWidgets('should_push_episodes_arrow_to_pill_right', (tester) async {
|
||||
// 宽屏下文案不会撑满胶囊,才能验证箭头是否被推到最右侧。
|
||||
await _pumpApp(tester, size: const Size(900, 844));
|
||||
@@ -1371,9 +1387,9 @@ void main() {
|
||||
final shareRect = tester.getRect(
|
||||
find.byKey(const Key('player-rail-share')),
|
||||
);
|
||||
expect(likeRect.top - saveRect.bottom, closeTo(12, 0.1));
|
||||
expect(commentsRect.top - likeRect.bottom, closeTo(12, 0.1));
|
||||
expect(shareRect.top - commentsRect.bottom, closeTo(12, 0.1));
|
||||
expect(likeRect.top - saveRect.bottom, closeTo(6, 0.1));
|
||||
expect(commentsRect.top - likeRect.bottom, closeTo(6, 0.1));
|
||||
expect(shareRect.top - commentsRect.bottom, closeTo(6, 0.1));
|
||||
|
||||
final saveButton = tester.widget<IconButton>(
|
||||
find.descendant(
|
||||
@@ -1405,7 +1421,9 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('should_toggle_synopsis_with_inline_text_button', (tester) async {
|
||||
testWidgets('should_toggle_synopsis_at_fixed_corner_with_feed_motion', (
|
||||
tester,
|
||||
) async {
|
||||
final longCopy = Drama(
|
||||
id: 'long-copy-demo',
|
||||
title: 'Long Title',
|
||||
@@ -1428,7 +1446,7 @@ void main() {
|
||||
await _pumpApp(tester, catalog: FakeCatalogRepository(dramas: [longCopy]));
|
||||
await _openSeriesPlayer(tester, feedIndex: 0);
|
||||
|
||||
final toggle = find.byKey(const Key('player-synopsis-toggle'));
|
||||
final toggle = find.byKey(const Key('player-synopsis-more'));
|
||||
final synopsis = find.byKey(const Key('player-synopsis'));
|
||||
expect(
|
||||
find.descendant(of: toggle, matching: find.text('More')),
|
||||
@@ -1443,17 +1461,15 @@ void main() {
|
||||
?.color,
|
||||
AppColors.textPrimary,
|
||||
);
|
||||
expect(tester.widget<Text>(synopsis).maxLines, 1);
|
||||
expect(tester.widget<Text>(synopsis).maxLines, 2);
|
||||
expect(tester.widget<Text>(synopsis).overflow, TextOverflow.ellipsis);
|
||||
// 行尾「展开」与正文同一行,只留 6 的左边距。
|
||||
expect(
|
||||
tester
|
||||
.widget<Padding>(
|
||||
find.descendant(of: toggle, matching: find.byType(Padding)),
|
||||
)
|
||||
.padding,
|
||||
const EdgeInsets.only(left: 6),
|
||||
final motion = tester.widget<AnimatedSize>(
|
||||
find.byKey(const Key('player-synopsis-size')),
|
||||
);
|
||||
expect(motion.duration, const Duration(milliseconds: 240));
|
||||
expect(motion.curve, Curves.easeInOutCubic);
|
||||
final blockFinder = find.byKey(const Key('player-episode-synopsis'));
|
||||
final collapsedHeight = tester.getSize(blockFinder).height;
|
||||
final synopsisRect = tester.getRect(synopsis);
|
||||
final toggleRect = tester.getRect(toggle);
|
||||
expect(toggleRect.left, greaterThan(synopsisRect.left));
|
||||
@@ -1463,24 +1479,24 @@ void main() {
|
||||
);
|
||||
|
||||
await tester.tap(toggle);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 80));
|
||||
final growingHeight = tester.getSize(blockFinder).height;
|
||||
expect(growingHeight, greaterThan(collapsedHeight));
|
||||
expect(tester.getRect(toggle), toggleRect);
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
find.descendant(of: toggle, matching: find.text('Less')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
tester.widget<Text>(find.byKey(const Key('player-synopsis'))).maxLines,
|
||||
tester
|
||||
.widget<Text>(find.byKey(const Key('player-synopsis-expanded')))
|
||||
.maxLines,
|
||||
isNull,
|
||||
);
|
||||
final lessRect = tester.getRect(toggle);
|
||||
final textRect = tester.getRect(find.byKey(const Key('player-synopsis')));
|
||||
final block = tester.getRect(
|
||||
find.byKey(const Key('player-episode-synopsis')),
|
||||
);
|
||||
// 展开后「收起」内联在正文最后一行的行尾。
|
||||
expect(lessRect.top, greaterThanOrEqualTo(textRect.top));
|
||||
expect(lessRect.bottom, lessThanOrEqualTo(textRect.bottom + 1));
|
||||
expect(lessRect.right - block.right, lessThanOrEqualTo(2));
|
||||
expect(tester.getRect(toggle), toggleRect);
|
||||
expect(tester.getSize(blockFinder).height, greaterThan(growingHeight));
|
||||
|
||||
await tester.tap(toggle);
|
||||
await tester.pumpAndSettle();
|
||||
@@ -1488,29 +1504,24 @@ void main() {
|
||||
find.descendant(of: toggle, matching: find.text('More')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(tester.widget<Text>(synopsis).maxLines, 1);
|
||||
expect(tester.widget<Text>(synopsis).maxLines, 2);
|
||||
});
|
||||
|
||||
testWidgets('should_share_one_text_style_across_label_and_synopsis', (
|
||||
testWidgets('should_match_feed_synopsis_without_episode_prefix', (
|
||||
tester,
|
||||
) async {
|
||||
await _pumpApp(tester);
|
||||
await _openSeriesPlayer(tester);
|
||||
|
||||
// 新 UI 把「第 n 集」与简介合并进同一个富文本,整行共用一套白字规格。
|
||||
final line = tester.widget<Text>(find.byKey(const Key('player-synopsis')));
|
||||
final style = line.style ?? const TextStyle();
|
||||
expect(style.fontSize, 12);
|
||||
expect(style.fontWeight, FontWeight.w500);
|
||||
expect(style.color, AppColors.onPrimary);
|
||||
|
||||
final span = line.textSpan! as TextSpan;
|
||||
final separator = span.children!.whereType<TextSpan>().firstWhere(
|
||||
(child) => (child.text ?? '').contains('|'),
|
||||
);
|
||||
expect(separator.style?.fontSize, 12);
|
||||
expect(separator.style?.fontWeight, FontWeight.w400);
|
||||
expect(separator.style?.color, AppColors.onPrimary.withValues(alpha: 0.45));
|
||||
expect(style.fontSize, 15);
|
||||
expect(style.fontWeight, FontWeight.w400);
|
||||
expect(style.color, AppColors.overlaySecondary);
|
||||
expect(style.shadows ?? <Shadow>[], isEmpty);
|
||||
expect(line.textSpan!.toPlainText(), isNot(startsWith('Episode ')));
|
||||
expect(line.textSpan!.toPlainText(), isNot(contains(' | ')));
|
||||
expect(find.byKey(const Key('player-episode-heading')), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('should_fit_long_title_and_synopsis_at_320_width', (
|
||||
@@ -1563,7 +1574,7 @@ void main() {
|
||||
tester.getRect(find.byKey(const Key('player-synopsis'))).right,
|
||||
lessThanOrEqualTo(320 - 14 - 54),
|
||||
);
|
||||
expect(synopsis.maxLines, 1);
|
||||
expect(synopsis.maxLines, 2);
|
||||
expect(synopsis.overflow, TextOverflow.ellipsis);
|
||||
expect(
|
||||
tester.getRect(find.byKey(const Key('player-episodes-pill'))).right,
|
||||
|
||||
Reference in new issue
Block a user