Author SHA1 Message Date
Zhou Weidong 12b1a11bd3 feat(ui): 更新底栏图标与播放加载视觉 2026-10-31 14:35:26 +08:00
Zhou Weidong 8ec6df2be5 merge: 修复 iOS 系统分享面板
flutter-ci / test (push) Canceled after 0s
2026-10-31 13:40:14 +08:00
Zhou Weidong 5ec4db151a fix(ios): 修复分享面板并同步互动栏布局断言 2026-10-31 13:33:13 +08:00
Vicky a47af8c3ad style(player): 视频信息区文案统一为 65% 半透明白
flutter-ci / test (push) Canceled after 0s
2026-10-31 09:41:19 +08:00
Vicky 2ddf9ee1c6 test(config): 服务地址断言跟随环境开关
flutter-ci / test (push) Canceled after 0s
2026-10-31 00:02:53 +08:00
Vicky 3bc79ad8e2 fix(i18n): 去掉印尼语内容完整性过滤,缺失时回退中文 2026-10-31 00:02:53 +08:00
33 changed files with 694 additions and 216 deletions

No files matched your search

+48 -6
View File
@@ -386,15 +386,29 @@ final class PipShadowCoordinator: NSObject, AVPictureInPictureControllerDelegate
guard call.method == "shareText",
let arguments = call.arguments as? [String: Any],
let text = arguments["text"] as? String,
!text.isEmpty,
let controller = self?.window?.rootViewController else {
!text.isEmpty else {
result(FlutterError(code: "share_unavailable", message: "Share sheet is unavailable", details: nil))
return
}
let activity = UIActivityViewController(activityItems: [text], applicationActivities: nil)
activity.popoverPresentationController?.sourceView = controller.view
activity.completionWithItemsHandler = { _, _, _, _ in result(nil) }
controller.present(activity, animated: true)
DispatchQueue.main.async {
guard let controller = self?.sharePresenter() else {
result(FlutterError(code: "share_unavailable", message: "Share sheet is unavailable", details: nil))
return
}
let activity = UIActivityViewController(activityItems: [text], applicationActivities: nil)
if let popover = activity.popoverPresentationController {
popover.sourceView = controller.view
popover.sourceRect = CGRect(
x: controller.view.bounds.midX,
y: controller.view.bounds.midY,
width: 0,
height: 0
)
popover.permittedArrowDirections = []
}
activity.completionWithItemsHandler = { _, _, _, _ in result(nil) }
controller.present(activity, animated: true)
}
}
let thumbnailChannel = FlutterMethodChannel(
@@ -459,6 +473,34 @@ final class PipShadowCoordinator: NSObject, AVPictureInPictureControllerDelegate
}
}
/// 当前前台 Scene 的根控制器之上,最适合展示系统分享面板的控制器。
private func sharePresenter() -> UIViewController? {
let keyWindow = UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.first { $0.activationState == .foregroundActive }?
.windows.first { $0.isKeyWindow }
guard let rootController = keyWindow?.rootViewController else { return nil }
let presenter = topPresentedController(from: rootController)
guard presenter.viewIfLoaded?.window != nil else { return nil }
return presenter
}
/// 解析已展示页面、导航栈和标签页中的当前可见控制器。
private func topPresentedController(from controller: UIViewController) -> UIViewController {
if let presented = controller.presentedViewController {
return topPresentedController(from: presented)
}
if let navigation = controller as? UINavigationController,
let visible = navigation.visibleViewController {
return topPresentedController(from: visible)
}
if let tabs = controller as? UITabBarController,
let selected = tabs.selectedViewController {
return topPresentedController(from: selected)
}
return controller
}
/// iOS 画中画:影子播放器通道,与 Android 共用同一 channel 名。
private func registerPipChannel(_ engineBridge: FlutterImplicitEngineBridge) {
let channel = FlutterMethodChannel(
+2 -2
View File
@@ -241,11 +241,11 @@ class Drama {
}
String localizedTitle(String languageCode) {
return localizedRequiredText(languageCode, title, titleLocales);
return localizedText(languageCode, title, titleLocales);
}
String localizedSynopsis(String languageCode) {
return localizedRequiredText(languageCode, synopsis, synopsisLocales);
return localizedText(languageCode, synopsis, synopsisLocales);
}
String localizedTag(String languageCode) {
-13
View File
@@ -18,16 +18,3 @@ String localizedText(
if (chinese.isNotEmpty) return chinese;
return fallback;
}
/// 按当前语言取必须本地化的文案;印尼语缺失时不回退中文。
String localizedRequiredText(
String languageCode,
String fallback,
Map<String, String> locales,
) {
final primaryCode = languageCode.split(RegExp(r'[_-]')).first;
final selected = locales[languageCode] ?? locales[primaryCode] ?? '';
if (selected.trim().isNotEmpty) return selected;
if (primaryCode == 'id') return '';
return localizedText(primaryCode, fallback, locales);
}
@@ -1,21 +0,0 @@
// Author: fruit.com.cn
import '../data/models/drama.dart';
const String _indonesianLanguageCode = 'id';
/// 判断短剧是否满足当前界面语言的基础文案完整度。
bool isDramaCompleteForLocale(Drama drama, String languageCode) {
final primaryCode = languageCode.split(RegExp(r'[_-]')).first;
if (primaryCode != _indonesianLanguageCode) return true;
return drama.localizedTitle(primaryCode).trim().isNotEmpty &&
drama.localizedSynopsis(primaryCode).trim().isNotEmpty;
}
/// 过滤不满足首发语言文案完整度的短剧,保持原顺序。
List<Drama> visibleDramasForLocale(
Iterable<Drama> dramas,
String languageCode,
) => dramas
.where((drama) => isDramaCompleteForLocale(drama, languageCode))
.toList(growable: false);
+3
View File
@@ -27,6 +27,9 @@ abstract final class AppColors {
static const overlaySecondary = Color(0xFFC7C7CC);
static const overlayLabel = Color(0xFFE6E6EB);
/// 首页及剧集播放页视频信息文案:参考设计图的 65% 半透明白。
static const homeFeedVideoCopy = Color(0xA6FFFFFF);
/// 主操作、选中态、播放进度。旧品牌橙已全量映射为品牌红(2026-10 改版)。
static const blue = Color(0xFFF04E4E);
static const primary = blue;
+24 -17
View File
@@ -18,6 +18,7 @@ class AppBottomNavItem {
this.selected = false,
this.assetPath,
this.selectedAssetPath,
this.customIcon,
});
final Key tabKey;
@@ -25,6 +26,7 @@ class AppBottomNavItem {
final IconData selectedIcon;
final String? assetPath;
final String? selectedAssetPath;
final Widget? customIcon;
final String label;
final VoidCallback onTap;
final bool selected;
@@ -93,26 +95,31 @@ class _NavDestination extends StatelessWidget {
duration: const Duration(milliseconds: 160),
curve: Curves.easeOut,
scale: item.selected ? 1 : 0.96,
child: assetPath == null
? Icon(
fallbackIcon,
size: AppDimens.navIcon,
color: color,
)
: ColorFiltered(
colorFilter: ColorFilter.mode(color, BlendMode.srcIn),
child: Image.asset(
assetPath,
width: AppDimens.navIcon,
height: AppDimens.navIcon,
fit: BoxFit.contain,
errorBuilder: (_, _, _) => Icon(
child:
item.customIcon ??
(assetPath == null
? Icon(
fallbackIcon,
size: AppDimens.navIcon,
color: color,
),
),
),
)
: ColorFiltered(
colorFilter: ColorFilter.mode(
color,
BlendMode.srcIn,
),
child: Image.asset(
assetPath,
width: AppDimens.navIcon,
height: AppDimens.navIcon,
fit: BoxFit.contain,
errorBuilder: (_, _, _) => Icon(
fallbackIcon,
size: AppDimens.navIcon,
color: color,
),
),
)),
),
const SizedBox(height: AppDimens.navLabelGap),
Text(
+14
View File
@@ -2,8 +2,10 @@
import 'package:flutter/material.dart';
import '../theme/app_dimens.dart';
import '../../l10n/app_localizations.dart';
import 'app_bottom_nav.dart';
import 'root_nav_glyph.dart';
/// 客户端根导航的固定顺序。
enum AppRootTab {
@@ -16,6 +18,13 @@ enum AppRootTab {
}
extension AppRootTabUi on AppRootTab {
RootNavGlyphKind get glyphKind => switch (this) {
AppRootTab.home => RootNavGlyphKind.home,
AppRootTab.recommend => RootNavGlyphKind.recommend,
AppRootTab.following => RootNavGlyphKind.following,
AppRootTab.mine => RootNavGlyphKind.mine,
};
Key get tabKey => switch (this) {
AppRootTab.home => const Key('tab-home'),
AppRootTab.recommend => const Key('tab-recommend'),
@@ -71,6 +80,11 @@ class AppRootBottomNav extends StatelessWidget {
selectedIcon: tab.selectedIcon,
label: tab.label(l10n),
selected: tab == selected,
customIcon: RootNavGlyph(
kind: tab.glyphKind,
selected: tab == selected,
size: AppDimens.navIcon,
),
onTap: () => onSelected(tab),
),
],
+180
View File
@@ -0,0 +1,180 @@
// Author: fruit.com.cn
// 职责:绘制根导航使用的饱满品牌插画图标。
import 'package:flutter/material.dart';
import '../theme/app_colors.dart';
const double _designSize = 30;
/// 根导航四个入口对应的图标种类。
enum RootNavGlyphKind { home, recommend, following, mine }
/// 根导航插画图标,根据选中态使用品牌红和中性灰白配色。
class RootNavGlyph extends StatelessWidget {
const RootNavGlyph({
super.key,
required this.kind,
required this.selected,
required this.size,
});
final RootNavGlyphKind kind;
final bool selected;
final double size;
@override
Widget build(BuildContext context) {
return CustomPaint(
size: Size.square(size),
painter: _RootNavGlyphPainter(kind: kind, selected: selected),
);
}
}
class _RootNavGlyphPainter extends CustomPainter {
const _RootNavGlyphPainter({required this.kind, required this.selected});
final RootNavGlyphKind kind;
final bool selected;
Color get _main => selected ? AppColors.navSelected : const Color(0xFFD0D0D2);
Color get _light => selected ? AppColors.homeCrownGold : Colors.white;
Color get _shade =>
selected ? const Color(0xFF9F2935) : const Color(0xFF858589);
@override
void paint(Canvas canvas, Size size) {
canvas.save();
canvas.scale(size.width / _designSize);
switch (kind) {
case RootNavGlyphKind.home:
_paintHome(canvas);
case RootNavGlyphKind.recommend:
_paintRecommend(canvas);
case RootNavGlyphKind.following:
_paintFollowing(canvas);
case RootNavGlyphKind.mine:
_paintMine(canvas);
}
canvas.restore();
}
void _paintHome(Canvas canvas) {
final roof = Path()
..moveTo(3, 13)
..lineTo(15, 3)
..lineTo(27, 13)
..lineTo(24, 16)
..lineTo(15, 8.5)
..lineTo(6, 16)
..close();
canvas.drawShadow(roof, AppColors.shadow, 2, false);
canvas.drawPath(roof, _gradient(const Rect.fromLTWH(3, 3, 24, 13)));
final body = RRect.fromRectAndRadius(
const Rect.fromLTRB(6, 13, 24, 27),
const Radius.circular(3),
);
canvas.drawRRect(body, _gradient(const Rect.fromLTWH(6, 13, 18, 14)));
canvas.drawRRect(
RRect.fromRectAndRadius(
const Rect.fromLTRB(12, 18, 18, 27),
const Radius.circular(2),
),
Paint()..color = _shade,
);
canvas.drawRRect(
RRect.fromRectAndRadius(
const Rect.fromLTRB(8, 16, 11, 19),
const Radius.circular(1),
),
Paint()..color = _light,
);
}
void _paintRecommend(Canvas canvas) {
final body = RRect.fromRectAndRadius(
const Rect.fromLTRB(6, 4, 24, 27),
const Radius.circular(5),
);
canvas.drawShadow(Path()..addRRect(body), AppColors.shadow, 2, false);
canvas.drawRRect(body, _gradient(const Rect.fromLTWH(6, 4, 18, 23)));
for (var index = 0; index < 3; index++) {
final left = 9.0 + index * 5;
canvas.drawRRect(
RRect.fromRectAndRadius(
Rect.fromLTWH(left, 7, 3, 2.5),
const Radius.circular(1),
),
Paint()..color = _light,
);
}
final play = Path()
..moveTo(12, 13)
..quadraticBezierTo(12, 11.5, 13.5, 12.3)
..lineTo(19.5, 15.7)
..quadraticBezierTo(21, 16.5, 19.5, 17.3)
..lineTo(13.5, 20.7)
..quadraticBezierTo(12, 21.5, 12, 20)
..close();
canvas.drawPath(play, Paint()..color = _light);
}
void _paintFollowing(Canvas canvas) {
final page = RRect.fromRectAndRadius(
const Rect.fromLTRB(5, 4, 25, 27),
const Radius.circular(4),
);
canvas.drawShadow(Path()..addRRect(page), AppColors.shadow, 2, false);
canvas.drawRRect(page, _gradient(const Rect.fromLTWH(5, 4, 20, 23)));
canvas.drawRRect(
RRect.fromRectAndRadius(
const Rect.fromLTRB(8, 9, 17, 10.5),
const Radius.circular(0.7),
),
Paint()..color = _light,
);
canvas.drawRRect(
RRect.fromRectAndRadius(
const Rect.fromLTRB(8, 13, 16, 14.5),
const Radius.circular(0.7),
),
Paint()..color = _light,
);
final bookmark = Path()
..moveTo(18, 7)
..lineTo(22, 7)
..lineTo(22, 20)
..lineTo(20, 18)
..lineTo(18, 20)
..close();
canvas.drawPath(bookmark, Paint()..color = _light);
}
void _paintMine(Canvas canvas) {
final bounds = Rect.fromCircle(center: const Offset(15, 15), radius: 13);
canvas.drawShadow(Path()..addOval(bounds), AppColors.shadow, 2, false);
canvas.drawCircle(const Offset(15, 15), 13, _gradient(bounds));
canvas.drawCircle(const Offset(15, 11), 4, Paint()..color = _light);
final shoulders = Path()
..moveTo(7, 24)
..cubicTo(7.5, 19, 10.5, 17, 15, 17)
..cubicTo(19.5, 17, 22.5, 19, 23, 24)
..quadraticBezierTo(15, 29, 7, 24)
..close();
canvas.drawPath(shoulders, Paint()..color = _light);
canvas.drawCircle(const Offset(13.5, 11), 0.55, Paint()..color = _shade);
canvas.drawCircle(const Offset(16.5, 11), 0.55, Paint()..color = _shade);
}
Paint _gradient(Rect bounds) => Paint()
..shader = LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [_light, _main, _shade],
).createShader(bounds);
@override
bool shouldRepaint(covariant _RootNavGlyphPainter oldDelegate) =>
oldDelegate.kind != kind || oldDelegate.selected != selected;
}
@@ -4,7 +4,6 @@ import 'package:flutter/material.dart';
import '../../../core/data/models/drama.dart';
import '../../../core/formatting/error_text.dart';
import '../../../core/formatting/content_locale_guard.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/widgets/empty_state.dart';
import '../../../l10n/app_localizations.dart';
@@ -34,18 +33,10 @@ class DiscoverSearchResults extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final visibleResults = visibleDramasForLocale(
results,
Localizations.localeOf(context).languageCode,
);
if (!loading && error == null && visibleResults.isNotEmpty) {
if (!loading && error == null && results.isNotEmpty) {
return SliverPadding(
padding: _topPadding,
sliver: SliverDramaGrid(
l10n: l10n,
dramas: visibleResults,
onOpen: onOpen,
),
sliver: SliverDramaGrid(l10n: l10n, dramas: results, onOpen: onOpen),
);
}
return SliverPadding(
@@ -7,7 +7,6 @@ import 'package:provider/provider.dart';
import '../../../core/data/models/drama.dart';
import '../../../core/data/services/category_controller.dart';
import '../../../core/formatting/content_locale_guard.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/widgets/app_pull_to_refresh.dart';
import '../../../core/widgets/drama_grid_skeleton.dart';
@@ -78,10 +77,7 @@ class _HomeCategoryDramasState extends State<HomeCategoryDramas> {
final controller = context.watch<CategoryController>();
final categoryId = widget.lockedCategoryId ?? '';
final slot = categoryId.isEmpty ? null : controller.slotOf(categoryId);
final dramas = visibleDramasForLocale(
slot?.dramas ?? const <Drama>[],
Localizations.localeOf(context).languageCode,
);
final dramas = slot?.dramas ?? const <Drama>[];
return Column(
children: [
Expanded(
+3 -13
View File
@@ -12,7 +12,6 @@ import '../../../core/data/services/catalog_controller.dart';
import '../../../core/data/services/category_controller.dart';
import '../../../core/data/services/home_feed_controller.dart';
import '../../../core/data/services/wallet_controller.dart';
import '../../../core/formatting/content_locale_guard.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/widgets/app_pull_to_refresh.dart';
@@ -195,10 +194,7 @@ class _HomeFeedGridState extends State<HomeFeedGrid> {
mainAxisSpacing: AppDimens.homeGridRowGap,
);
}
final dramas = _dramasFor(
category,
Localizations.localeOf(context).languageCode,
);
final dramas = _dramasFor(category);
if (widget.feedController.loadingFirstPage && dramas.isEmpty) {
return const DramaGridSkeleton(
key: Key('home-grid-skeleton'),
@@ -328,14 +324,8 @@ class _HomeFeedGridState extends State<HomeFeedGrid> {
];
}
List<Drama> _dramasFor(
HomeGridCategoryShortcut category,
String languageCode,
) {
final dramas = visibleDramasForLocale(
widget.feedController.dramas,
languageCode,
);
List<Drama> _dramasFor(HomeGridCategoryShortcut category) {
final dramas = widget.feedController.dramas;
final categoryName = category.categoryName.toLowerCase();
if (categoryName.isEmpty) return dramas;
final matched = dramas
@@ -12,7 +12,6 @@ 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,
@@ -32,6 +31,7 @@ class ExpandableSynopsis extends StatefulWidget {
required this.synopsis,
this.keyPrefix = 'home-feed',
this.expandedFooter,
this.textColor = AppColors.homeFeedVideoCopy,
});
/// 按当前语言获取的简介正文。
@@ -43,6 +43,9 @@ class ExpandableSynopsis extends StatefulWidget {
/// 展开后附加的声明等内容。
final Widget? expandedFooter;
/// 简介正文颜色;默认使用视频信息区统一的半透明浅灰白。
final Color textColor;
@override
State<ExpandableSynopsis> createState() => _ExpandableSynopsisState();
}
@@ -88,7 +91,7 @@ class _ExpandableSynopsisState extends State<ExpandableSynopsis> {
}
bool _needsToggle(double maxWidth) {
final painter = _painter(widget.synopsis, _synopsisStyle);
final painter = _painter(widget.synopsis, _bodyStyle);
painter.layout(maxWidth: maxWidth);
final overflows = painter.didExceedMaxLines;
painter.dispose();
@@ -120,11 +123,14 @@ class _ExpandableSynopsisState extends State<ExpandableSynopsis> {
Key _key(String suffix) => Key('${widget.keyPrefix}-synopsis-$suffix');
TextStyle get _bodyStyle => _synopsisStyle.copyWith(color: widget.textColor);
Widget _body() {
final style = _bodyStyle;
final text = Text.rich(
TextSpan(text: widget.synopsis, style: _synopsisStyle),
TextSpan(text: widget.synopsis, style: style),
key: _expanded ? _key('expanded') : Key('${widget.keyPrefix}-synopsis'),
style: _synopsisStyle,
style: style,
maxLines: _expanded ? null : _collapsedSynopsisLines,
overflow: _expanded ? TextOverflow.clip : TextOverflow.ellipsis,
);
@@ -101,7 +101,7 @@ class _TagChip extends StatelessWidget {
maxLines: 1,
strutStyle: _metadataStrutStyle,
style: const TextStyle(
color: AppColors.overlaySecondary,
color: AppColors.homeFeedVideoCopy,
fontSize: _tagFont,
fontWeight: FontWeight.w500,
height: _metadataLineHeight,
@@ -27,6 +27,7 @@ const double _progressHeight = 2;
const double _progressHitHeight = 16;
const double _pageHorizontal = 20;
const double _railRight = 14;
const double _railShiftRight = 8;
const double _railGap = 12;
const double _railWidth = 56;
const double _infoWatchAllGap = 2;
@@ -234,7 +235,10 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
padding: const EdgeInsets.only(
bottom: _railBottomOffset,
),
child: _rail(),
child: Transform.translate(
offset: const Offset(_railShiftRight, 0),
child: _rail(),
),
),
),
],
@@ -103,6 +103,7 @@ class _HomeFeedInfoPanelState extends State<HomeFeedInfoPanel> {
ExpandableSynopsis(
key: ValueKey(widget.drama.id),
synopsis: synopsis,
textColor: AppColors.homeFeedVideoCopy,
),
],
],
@@ -3,8 +3,19 @@
import 'package:flutter/material.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
const _fadeDuration = Duration(milliseconds: 140);
const _motionDuration = Duration(milliseconds: 1350);
const _surfaceSize = Size(56, 36);
const _logoSize = Size(48, 29);
const _minimumScale = 0.98;
const _staticProgress = 0.5;
const _waveMultiplier = 2.0;
const _sweepStart = -1.2;
const _sweepTravel = 3.4;
const _sweepFeather = 0.16;
/// 剧集播放页首帧出现前的非阻塞 YMB 品牌加载层。
class PlayerLoadingOverlay extends StatelessWidget {
const PlayerLoadingOverlay({
super.key,
@@ -19,27 +30,124 @@ class PlayerLoadingOverlay extends StatelessWidget {
Widget build(BuildContext context) {
return AnimatedOpacity(
opacity: opacity,
duration: const Duration(milliseconds: 140),
duration: _fadeDuration,
curve: Curves.easeOut,
onEnd: onFadeEnd,
child: Container(
key: const Key('player-loading-surface'),
width: AppDimens.refreshIndicatorSize,
height: AppDimens.refreshIndicatorSize,
decoration: BoxDecoration(
color: AppColors.refreshIndicatorSurface,
borderRadius: BorderRadius.circular(AppDimens.refreshIndicatorRadius),
),
width: _surfaceSize.width,
height: _surfaceSize.height,
alignment: Alignment.center,
child: const SizedBox.square(
dimension: AppDimens.refreshSpinnerSize,
child: CircularProgressIndicator(
key: Key('player-entry-loading'),
color: Colors.white,
strokeWidth: AppDimens.refreshSpinnerStroke,
),
),
child: const _YmbLoadingMark(),
),
);
}
}
class _YmbLoadingMark extends StatefulWidget {
const _YmbLoadingMark();
@override
State<_YmbLoadingMark> createState() => _YmbLoadingMarkState();
}
class _YmbLoadingMarkState extends State<_YmbLoadingMark>
with SingleTickerProviderStateMixin, WidgetsBindingObserver {
late final AnimationController _controller;
bool _disableAnimations = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_controller = AnimationController(vsync: this, duration: _motionDuration);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final disableAnimations = MediaQuery.disableAnimationsOf(context);
if (_disableAnimations == disableAnimations && _controller.isAnimating) {
return;
}
_disableAnimations = disableAnimations;
_syncMotion(WidgetsBinding.instance.lifecycleState);
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
_syncMotion(state);
}
void _syncMotion(AppLifecycleState? state) {
if (_disableAnimations) {
_controller
..stop()
..value = _staticProgress;
return;
}
if (state == null || state == AppLifecycleState.resumed) {
if (!_controller.isAnimating) {
_controller.repeat();
}
return;
}
_controller.stop();
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, child) {
final progress = _controller.value;
return _buildLogo(progress);
},
);
}
Widget _buildLogo(double progress) {
final pulse = Curves.easeInOut.transform(_triangleWave(progress));
final scale = _minimumScale + (1 - _minimumScale) * pulse;
return Transform.scale(
key: const Key('player-entry-loading-motion'),
scale: scale,
child: ShaderMask(
blendMode: BlendMode.srcATop,
shaderCallback: (bounds) =>
_logoGradient(progress).createShader(bounds),
child: Image.asset(
'assets/icon/splash_logo.png',
key: const Key('player-entry-loading'),
width: _logoSize.width,
height: _logoSize.height,
fit: BoxFit.contain,
),
),
);
}
LinearGradient _logoGradient(double progress) {
final center = _sweepStart + progress * _sweepTravel;
return LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: const [
Colors.white,
Colors.white,
AppColors.brandRed,
Colors.white,
],
stops: [0, (center - _sweepFeather).clamp(0, 1), center.clamp(0, 1), 1],
);
}
double _triangleWave(double value) => 1 - (value * _waveMultiplier - 1).abs();
}
@@ -9,7 +9,6 @@ import '../../core/data/models/drama.dart';
import '../../core/data/models/home_feed.dart';
import '../../core/data/remote/catalog_api.dart';
import '../../core/data/services/category_controller.dart';
import '../../core/formatting/content_locale_guard.dart';
import '../../core/theme/app_colors.dart';
import '../../core/widgets/app_pull_to_refresh.dart';
import '../../core/widgets/empty_state.dart';
@@ -285,10 +284,7 @@ class _ChannelFeedState extends State<_ChannelFeed>
super.build(context);
final categories = context.watch<CategoryController>();
final slot = categories.slotOf(widget.categoryId);
final dramas = visibleDramasForLocale(
slot.dramas,
Localizations.localeOf(context).languageCode,
);
final dramas = slot.dramas;
final showCarousel = widget.isFeatured && widget.banners.isNotEmpty;
final bottomInset = MediaQuery.of(context).padding.bottom;
+1 -5
View File
@@ -13,7 +13,6 @@ import '../../core/data/services/download_controller.dart';
import '../../core/data/services/home_feed_controller.dart';
import '../../core/data/services/playlist_controller.dart';
import '../../core/data/services/wallet_controller.dart';
import '../../core/formatting/content_locale_guard.dart';
import '../../core/theme/app_colors.dart';
import '../../core/widgets/empty_state.dart';
import '../../l10n/app_localizations.dart';
@@ -299,10 +298,7 @@ class _RecommendPageState extends State<RecommendPage>
Widget _recommendFeed(BuildContext context) {
final feed = widget.feedController;
final dramas = visibleDramasForLocale(
widget.feedController.dramas,
Localizations.localeOf(context).languageCode,
);
final dramas = widget.feedController.dramas;
if (dramas.isNotEmpty) {
return PlayerPage(
key: const Key('recommend-feed-player'),
@@ -10,7 +10,6 @@ import '../../../core/data/models/drama.dart';
import '../../../core/data/remote/catalog_api.dart';
import '../../../core/data/services/all_dramas_controller.dart';
import '../../../core/data/services/home_feed_controller.dart';
import '../../../core/formatting/content_locale_guard.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/widgets/app_pull_to_refresh.dart';
@@ -108,10 +107,7 @@ class _AllDramasFilterGridState extends State<AllDramasFilterGrid> {
}
Widget _dramaList(AppLocalizations l10n, double itemExtent) {
final dramas = visibleDramasForLocale(
_dramas,
Localizations.localeOf(context).languageCode,
);
final dramas = _dramas;
return NotificationListener<ScrollNotification>(
onNotification: _onScroll,
child: AppPullToRefresh(
@@ -4,7 +4,6 @@ import 'package:flutter/material.dart';
import '../../../core/data/models/drama.dart';
import '../../../core/data/models/playlist_detail.dart';
import '../../../core/formatting/content_locale_guard.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/widgets/app_pull_to_refresh.dart';
@@ -158,10 +157,7 @@ class _PlaylistDetailPageState extends State<PlaylistDetailPage> {
}
Widget _body(double itemExtent) {
final visibleDramas = visibleDramasForLocale(
_dramas,
Localizations.localeOf(context).languageCode,
);
final visibleDramas = _dramas;
return CustomScrollView(
key: const Key('playlist-detail-scroll'),
physics: AppPullToRefresh.physics,
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import '../../../core/data/models/content_category.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/widgets/animated_nav_label.dart';
import '../../../l10n/app_localizations.dart';
import 'search_gift_glyphs.dart';
@@ -11,7 +12,7 @@ import 'search_gift_glyphs.dart';
const double _navHeight = 56;
const double _tabGap = 20;
const double _tabFont = 17;
const double _searchHitSize = 40;
const double _searchHitSize = AppDimens.tapTarget;
const double _searchIconSize = 26;
const double _giftIconSize = 26;
const double _underlineWidth = 26;
@@ -8,7 +8,6 @@ import '../../../core/data/models/playlist_detail.dart';
import '../../../core/data/services/category_controller.dart';
import '../../../core/data/services/home_feed_controller.dart';
import '../../../core/data/services/playlist_controller.dart';
import '../../../core/formatting/content_locale_guard.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/widgets/app_pull_to_refresh.dart';
@@ -44,8 +43,7 @@ class RecommendSectionsView extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final languageCode = Localizations.localeOf(context).languageCode;
final source = visibleDramasForLocale(feedController.dramas, languageCode);
final source = feedController.dramas;
final remote = playlists;
return AppPullToRefresh(
key: const Key('recommend-sections-refresh'),
@@ -165,10 +163,7 @@ class RecommendSectionsView extends StatelessWidget {
subtitle: playlist.localizedDescription(
Localizations.localeOf(context).languageCode,
),
dramas: visibleDramasForLocale(
playlist.dramas,
Localizations.localeOf(context).languageCode,
).take(_sectionPreviewCount).toList(),
dramas: playlist.dramas.take(_sectionPreviewCount).toList(),
onMore: () => onOpenPlaylist?.call(playlist),
),
);
@@ -39,7 +39,7 @@ class RecommendTopNav extends StatelessWidget {
/// 首页横向页面的连续位置,用于文字和下划线跟手过渡。
final double? pagePosition;
/// 右上角金色礼物盒(钱包入口);为空时不显示。
/// 右上角礼包入口;为空时不显示。
final VoidCallback? onGift;
@override
@@ -50,8 +50,12 @@ class _SearchPainter extends CustomPainter {
final center = Offset(circleDiameter / 2, circleDiameter / 2);
canvas.drawCircle(center, circleDiameter / 2 - stroke / 2, paint);
final start = center + Offset.fromDirection(direction, circleDiameter / 2 - stroke * 0.2);
final end = center + Offset.fromDirection(direction, circleDiameter / 2 + size.width * 0.12);
final start =
center +
Offset.fromDirection(direction, circleDiameter / 2 - stroke * 0.2);
final end =
center +
Offset.fromDirection(direction, circleDiameter / 2 + size.width * 0.12);
canvas.drawLine(start, end, paint);
}
@@ -60,7 +64,7 @@ class _SearchPainter extends CustomPainter {
oldDelegate.color != color;
}
/// emoji 礼盒(红盒金丝带,随系统 emoji 风格渲染)。
/// YMB 品牌礼盒静态图标,使用玫红礼盒与金色丝带。
class GiftGlyph extends StatelessWidget {
const GiftGlyph({super.key, required this.size});
@@ -68,11 +72,94 @@ class GiftGlyph extends StatelessWidget {
@override
Widget build(BuildContext context) {
return SizedBox.square(
dimension: size,
child: FittedBox(
child: Text('🎁', style: TextStyle(fontSize: size, height: 1)),
),
);
return CustomPaint(size: Size.square(size), painter: _GiftPainter());
}
}
class _GiftPainter extends CustomPainter {
const _GiftPainter();
static const double _designSize = 26;
@override
void paint(Canvas canvas, Size size) {
canvas.save();
canvas.scale(size.width / _designSize);
_paintBox(canvas);
_paintRibbon(canvas);
_paintBow(canvas);
canvas.restore();
}
void _paintBox(Canvas canvas) {
final body = RRect.fromRectAndRadius(
const Rect.fromLTRB(4, 13, 22, 23),
const Radius.circular(2),
);
canvas.drawRRect(
body,
Paint()
..shader = const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFFF5C82), Color(0xFFEF315D)],
).createShader(const Rect.fromLTRB(4, 13, 22, 23)),
);
canvas.drawRRect(
RRect.fromRectAndRadius(
const Rect.fromLTRB(3, 10, 23, 14),
const Radius.circular(1.5),
),
Paint()
..shader = const LinearGradient(
colors: [Color(0xFFFF7190), Color(0xFFF52E5C)],
).createShader(const Rect.fromLTRB(3, 10, 23, 14)),
);
}
void _paintRibbon(Canvas canvas) {
final ribbon = Paint()
..shader = const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFFFFFFA8), Color(0xFFFFD52F), Color(0xFFFFB900)],
).createShader(const Rect.fromLTRB(10.5, 10, 15.5, 23));
canvas.drawRect(const Rect.fromLTRB(10.5, 13, 15.5, 23), ribbon);
canvas.drawRect(const Rect.fromLTRB(3, 10.5, 23, 14), ribbon);
canvas.drawLine(
const Offset(11, 14),
const Offset(11, 22),
Paint()
..color = const Color(0xFFFFFFAE)
..strokeWidth = 0.6,
);
}
void _paintBow(Canvas canvas) {
final gold = Paint()
..shader = const LinearGradient(
colors: [Color(0xFFFFFFA8), Color(0xFFFFD52F), Color(0xFFFFB900)],
).createShader(const Rect.fromLTRB(5, 1, 21, 12));
final tails = Path()
..moveTo(11.8, 7)
..lineTo(15, 7)
..lineTo(17, 13)
..lineTo(14.1, 12)
..lineTo(12.6, 14)
..lineTo(10.8, 8)
..close();
canvas.drawPath(tails, gold);
final bow = Path()
..moveTo(13, 7)
..cubicTo(5.2, 0, 5.2, 4.8, 7.5, 6.7)
..cubicTo(9, 8, 11.5, 8, 13, 7)
..cubicTo(14.5, 8, 17, 8, 18.5, 6.7)
..cubicTo(20.8, 4.8, 20.8, 0, 13, 7)
..close();
canvas.drawPath(bow, gold);
canvas.drawCircle(const Offset(13, 7), 1.55, gold);
}
@override
bool shouldRepaint(covariant _GiftPainter oldDelegate) => false;
}
+10 -5
View File
@@ -62,10 +62,12 @@ void main() {
});
test('should_pick_address_by_environment_switch', () {
// 仓库里的开关默认指向开发环境。
expect(AppConfig.useDevConfig, isTrue);
expect(AppConfig.baseUrl, DevConfig.baseUrl);
expect(AppConfig.apiBaseUrl, DevConfig.baseUrl);
// 跟随开关:开发分支为 true(开发地址),发版分支为 false(生产地址)。
final expected = AppConfig.useDevConfig
? DevConfig.baseUrl
: ProdConfig.baseUrl;
expect(AppConfig.baseUrl, expected);
expect(AppConfig.apiBaseUrl, expected);
expect(Uri.parse(AppConfig.baseUrl).port, 8081);
});
@@ -87,7 +89,10 @@ void main() {
AppConfig.applyRuntimeBaseUrl('http://runtime.test:8081');
expect(AppConfig.apiBaseUrl, 'http://runtime.test:8081');
expect(AppConfig.baseUrl, DevConfig.baseUrl);
expect(
AppConfig.baseUrl,
AppConfig.useDevConfig ? DevConfig.baseUrl : ProdConfig.baseUrl,
);
});
test('should_append_host_to_relative_media_paths', () {
+3 -31
View File
@@ -1,7 +1,6 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/models/drama.dart';
import 'package:fruitshorts/core/formatting/localized_content.dart';
import 'package:fruitshorts/core/formatting/content_locale_guard.dart';
import 'package:fruitshorts/l10n/app_localizations_en.dart';
import 'package:fruitshorts/l10n/app_localizations_id.dart';
import 'package:fruitshorts/l10n/app_localizations_zh.dart';
@@ -77,7 +76,7 @@ void main() {
expect(localizedDramaSynopsis(zh, custom), 'Custom synopsis');
});
test('should_not_fall_back_to_chinese_when_indonesian_copy_is_missing', () {
test('should_fall_back_to_chinese_when_indonesian_copy_is_missing', () {
const drama = Drama(
id: 'missing-id-copy',
title: '古城',
@@ -87,35 +86,8 @@ void main() {
posterUrl: '/files/poster.png',
);
expect(drama.localizedTitle('id'), isEmpty);
expect(drama.localizedSynopsis('id'), isEmpty);
});
test('should_filter_incomplete_indonesian_content_from_visible_list', () {
const complete = Drama(
id: 'complete-id-copy',
title: 'Kota Lama',
synopsis: 'Kisah dari kota lama.',
titleLocales: {'id': 'Kota Lama'},
synopsisLocales: {'id': 'Kisah dari kota lama.'},
posterUrl: '/files/complete.png',
);
const incomplete = Drama(
id: 'incomplete-id-copy',
title: '古城',
synopsis: '中文简介',
titleLocales: {'zh': '古城'},
synopsisLocales: {'zh': '中文简介'},
posterUrl: '/files/incomplete.png',
);
expect(isDramaCompleteForLocale(complete, 'id'), isTrue);
expect(isDramaCompleteForLocale(incomplete, 'id'), isFalse);
expect(visibleDramasForLocale([complete, incomplete], 'id'), [complete]);
expect(visibleDramasForLocale([complete, incomplete], 'en'), [
complete,
incomplete,
]);
expect(drama.localizedTitle('id'), '古城');
expect(drama.localizedSynopsis('id'), '中文简介');
});
test('should_ignore_raw_catalog_text_when_drama_is_seeded', () {
+4 -4
View File
@@ -136,7 +136,7 @@ void _expectExpandedSynopsis(WidgetTester tester) {
final expanded = find.byKey(const Key('home-feed-synopsis-expanded'));
expect(
tester.widget<Text>(expanded).textSpan?.style?.color,
AppColors.overlaySecondary,
AppColors.homeFeedVideoCopy,
);
_expectNoShadows(tester);
_expectWatchGap(tester);
@@ -187,7 +187,7 @@ void main() {
matching: find.byType(Text),
),
);
expect(tag.style?.color, AppColors.overlaySecondary);
expect(tag.style?.color, AppColors.homeFeedVideoCopy);
});
testWidgets('should_skip_motion_when_system_disables_animations', (
@@ -343,7 +343,7 @@ void main() {
final synopsis = tester.widget<Text>(
find.byKey(const Key('home-feed-synopsis')),
);
expect(synopsis.style?.color, AppColors.overlaySecondary);
expect(synopsis.style?.color, AppColors.homeFeedVideoCopy);
expect(synopsis.style?.fontSize, 15);
expect(synopsis.maxLines, 2);
final episode = tester.widget<Text>(
@@ -353,7 +353,7 @@ void main() {
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, AppColors.homeFeedVideoCopy);
expect(tag.style?.color, synopsis.style?.color);
expect(tag.style?.fontSize, 13);
final hotSearch = tester.widget<Text>(
+17 -14
View File
@@ -11,6 +11,7 @@ import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart
import 'package:fruitshorts/core/theme/app_colors.dart';
import 'package:fruitshorts/core/theme/app_dimens.dart';
import 'package:fruitshorts/core/widgets/app_bottom_nav.dart';
import 'package:fruitshorts/core/widgets/root_nav_glyph.dart';
import 'package:fruitshorts/pages/following/following_page.dart';
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
@@ -138,18 +139,20 @@ void main() {
.duration,
lessThanOrEqualTo(const Duration(milliseconds: 200)),
);
final selectedHomeIcon = tester.widget<Icon>(
final selectedHomeIcon = tester.widget<RootNavGlyph>(
find.descendant(
of: find.byKey(const Key('tab-home')),
matching: find.byIcon(Icons.home_rounded),
matching: find.byType(RootNavGlyph),
),
);
expect(selectedHomeIcon.color, AppColors.navSelected);
expect(selectedHomeIcon.kind, RootNavGlyphKind.home);
expect(selectedHomeIcon.selected, isTrue);
expect(selectedHomeIcon.size, AppDimens.navIcon);
expect(
tester.getSize(
find.descendant(
of: find.byKey(const Key('tab-home')),
matching: find.byIcon(Icons.home_rounded),
matching: find.byType(RootNavGlyph),
),
),
const Size.square(AppDimens.navIcon),
@@ -157,21 +160,21 @@ void main() {
expect(
find.descendant(
of: find.byKey(const Key('tab-recommend')),
matching: find.byIcon(Icons.smart_display_outlined),
matching: find.byType(RootNavGlyph),
),
findsOneWidget,
);
expect(
find.descendant(
of: find.byKey(const Key('tab-follow')),
matching: find.byIcon(Icons.bookmark_border_rounded),
matching: find.byType(RootNavGlyph),
),
findsOneWidget,
);
expect(
find.descendant(
of: find.byKey(const Key('tab-mine')),
matching: find.byIcon(Icons.person_outline_rounded),
matching: find.byType(RootNavGlyph),
),
findsOneWidget,
);
@@ -188,7 +191,7 @@ void main() {
expect(
find.descendant(
of: find.byKey(const Key('tab-recommend')),
matching: find.byIcon(Icons.smart_display_rounded),
matching: find.byType(RootNavGlyph),
),
findsOneWidget,
);
@@ -219,20 +222,20 @@ void main() {
expect(
find.descendant(
of: find.byKey(const Key('tab-home')),
matching: find.byIcon(Icons.home_outlined),
matching: find.byType(RootNavGlyph),
),
findsOneWidget,
);
expect(
tester
.widget<Icon>(
.widget<RootNavGlyph>(
find.descendant(
of: find.byKey(const Key('tab-home')),
matching: find.byIcon(Icons.home_outlined),
matching: find.byType(RootNavGlyph),
),
)
.color,
AppColors.navUnselected,
.selected,
isFalse,
);
});
@@ -270,7 +273,7 @@ void main() {
expect(
find.descendant(
of: find.byKey(const Key('tab-follow')),
matching: find.byIcon(Icons.bookmark_rounded),
matching: find.byType(RootNavGlyph),
),
findsOneWidget,
);
+1 -1
View File
@@ -373,7 +373,7 @@ void main() {
expect(
tester.getRect(find.byKey(const Key('home-feed-action-rail'))).right,
closeTo(
tester.getRect(find.byKey(const Key('home-feed-gesture'))).right - 14,
tester.getRect(find.byKey(const Key('home-feed-gesture'))).right - 6,
0.1,
),
);
+38
View File
@@ -0,0 +1,38 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/widgets/app_root_tab.dart';
import 'package:fruitshorts/core/widgets/root_nav_glyph.dart';
import 'package:fruitshorts/l10n/app_localizations.dart';
void main() {
testWidgets(
'should_render_four_ordered_glyphs_with_home_selected_when_root_nav_builds',
(tester) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
bottomNavigationBar: AppRootBottomNav(
selected: AppRootTab.home,
onSelected: (_) {},
),
),
),
);
final glyphs = tester
.widgetList<RootNavGlyph>(find.byType(RootNavGlyph))
.toList();
expect(glyphs.map((glyph) => glyph.kind), RootNavGlyphKind.values);
expect(glyphs.map((glyph) => glyph.selected), [
true,
false,
false,
false,
]);
},
);
}
@@ -2,6 +2,7 @@
// 职责:验证共用简介的短文案、展开声明、内容更新与减少动画行为。
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/l10n/app_localizations.dart';
import 'package:fruitshorts/pages/player/widgets/expandable_synopsis.dart';
@@ -54,6 +55,14 @@ Future<void> _pump(
}
void main() {
testWidgets('should_use_video_copy_color_when_player_does_not_override', (
tester,
) async {
await _pump(tester, synopsis: _shortCopy);
final synopsis = tester.widget<Text>(find.byKey(_collapsed));
expect(synopsis.style?.color, AppColors.homeFeedVideoCopy);
});
testWidgets('should_hide_toggle_when_synopsis_fits_two_lines', (
tester,
) async {
+16 -13
View File
@@ -313,7 +313,7 @@ void main() {
.first;
expect(loadingOverlay, findsOneWidget);
expect(tester.widget<IgnorePointer>(loadingOverlay).ignoring, isTrue);
expect(tester.getSize(loadingOverlay), const Size(40, 40));
expect(tester.getSize(loadingOverlay), const Size(56, 36));
expect(
tester
.widget<AnimatedOpacity>(
@@ -327,26 +327,29 @@ void main() {
);
expect(
tester.getSize(find.byKey(const Key('player-loading-surface'))),
const Size(40, 40),
const Size(56, 36),
);
expect(
tester.getSize(find.byKey(const Key('player-entry-loading'))),
const Size(20, 20),
const Size(48, 29),
);
expect(
tester
.widget<CircularProgressIndicator>(
find.byKey(const Key('player-entry-loading')),
)
.color,
Colors.white,
find.descendant(
of: loadingOverlay,
matching: find.byType(CircularProgressIndicator),
),
findsNothing,
);
expect(find.text('Loading'), findsNothing);
expect(find.byKey(const Key('player-entry-loading-track')), findsNothing);
expect(
find.byKey(const Key('player-entry-loading-highlight')),
findsNothing,
);
final loadingSurface = tester.widget<Container>(
find.byKey(const Key('player-loading-surface')),
);
final decoration = loadingSurface.decoration! as BoxDecoration;
expect(decoration.color, AppColors.refreshIndicatorSurface);
expect(decoration.borderRadius, BorderRadius.circular(20));
expect(loadingSurface.decoration, isNull);
expect(
find.descendant(of: loadingOverlay, matching: find.byType(ColoredBox)),
findsNothing,
@@ -1517,7 +1520,7 @@ void main() {
final style = line.style ?? const TextStyle();
expect(style.fontSize, 15);
expect(style.fontWeight, FontWeight.w400);
expect(style.color, AppColors.overlaySecondary);
expect(style.color, AppColors.homeFeedVideoCopy);
expect(style.shadows ?? <Shadow>[], isEmpty);
expect(line.textSpan!.toPlainText(), isNot(startsWith('Episode ')));
expect(line.textSpan!.toPlainText(), isNot(contains(' | ')));
@@ -0,0 +1,73 @@
// Author: fruit.com.cn
// 职责:验证剧集播放页 YMB 入场 loading 的品牌视觉、运动和减少动画行为。
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/l10n/app_localizations.dart';
import 'package:fruitshorts/pages/player/widgets/player_loading_overlay.dart';
const _surface = Key('player-loading-surface');
const _logo = Key('player-entry-loading');
const _motion = Key('player-entry-loading-motion');
void main() {
testWidgets('should_show_ymb_motion_without_circular_spinner', (
tester,
) async {
await _pump(tester);
expect(find.byType(CircularProgressIndicator), findsNothing);
expect(find.byKey(_surface), findsOneWidget);
expect(find.byKey(_logo), findsOneWidget);
expect(find.text('Loading'), findsNothing);
expect(find.byKey(const Key('player-entry-loading-track')), findsNothing);
expect(
find.byKey(const Key('player-entry-loading-highlight')),
findsNothing,
);
expect(find.byType(ShaderMask), findsOneWidget);
expect(tester.getSize(find.byKey(_surface)), const Size(56, 36));
expect(tester.getSize(find.byKey(_logo)), const Size(48, 29));
expect(tester.widget<Container>(find.byKey(_surface)).decoration, isNull);
final initialScale = _scaleOf(tester);
await tester.pump(const Duration(milliseconds: 300));
expect(_scaleOf(tester), isNot(initialScale));
});
testWidgets('should_keep_loading_static_when_motion_is_disabled', (
tester,
) async {
await _pump(tester, disableAnimations: true);
final initialScale = _scaleOf(tester);
await tester.pump(const Duration(milliseconds: 300));
expect(_scaleOf(tester), initialScale);
expect(find.byKey(_logo), findsOneWidget);
expect(find.text('Loading'), findsNothing);
});
}
double _scaleOf(WidgetTester tester) {
return tester.widget<Transform>(find.byKey(_motion)).transform.storage.first;
}
Future<void> _pump(
WidgetTester tester, {
bool disableAnimations = false,
}) async {
await tester.pumpWidget(
MaterialApp(
locale: const Locale('en'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: MediaQuery(
data: MediaQueryData(disableAnimations: disableAnimations),
child: Scaffold(
body: Center(
child: PlayerLoadingOverlay(opacity: 1, onFadeEnd: () {}),
),
),
),
),
);
}