68 changed files with 4206 additions and 1048 deletions

No files matched your search

+1 -1
View File
@@ -200,7 +200,7 @@ flutter test
flutter build apk --debug flutter build apk --debug
``` ```
当前基线:`dart analyze` 仅 1 条既存 info(`intl` 未在 `pubspec.yaml` 声明); 当前基线:`dart analyze` 仅 1 条既存 info(`intl` 未在 `pubspec.yaml` 声明);
`flutter test` 全量 775 例通过。测试文件按模块放在 `test/` 下,用例名使用 `flutter test` 全量 775 例通过。测试文件按模块放在 `test/` 下,用例名使用
`should_<期望>_when_<条件>`。真实后端联调测试在服务未启动时会跳过。 `should_<期望>_when_<条件>`。真实后端联调测试在服务未启动时会跳过。
+48 -6
View File
@@ -386,15 +386,29 @@ final class PipShadowCoordinator: NSObject, AVPictureInPictureControllerDelegate
guard call.method == "shareText", guard call.method == "shareText",
let arguments = call.arguments as? [String: Any], let arguments = call.arguments as? [String: Any],
let text = arguments["text"] as? String, let text = arguments["text"] as? String,
!text.isEmpty, !text.isEmpty else {
let controller = self?.window?.rootViewController else {
result(FlutterError(code: "share_unavailable", message: "Share sheet is unavailable", details: nil)) result(FlutterError(code: "share_unavailable", message: "Share sheet is unavailable", details: nil))
return return
} }
let activity = UIActivityViewController(activityItems: [text], applicationActivities: nil) DispatchQueue.main.async {
activity.popoverPresentationController?.sourceView = controller.view guard let controller = self?.sharePresenter() else {
activity.completionWithItemsHandler = { _, _, _, _ in result(nil) } result(FlutterError(code: "share_unavailable", message: "Share sheet is unavailable", details: nil))
controller.present(activity, animated: true) 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( 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 名。 /// iOS 画中画:影子播放器通道,与 Android 共用同一 channel 名。
private func registerPipChannel(_ engineBridge: FlutterImplicitEngineBridge) { private func registerPipChannel(_ engineBridge: FlutterImplicitEngineBridge) {
let channel = FlutterMethodChannel( let channel = FlutterMethodChannel(
+2 -2
View File
@@ -241,11 +241,11 @@ class Drama {
} }
String localizedTitle(String languageCode) { String localizedTitle(String languageCode) {
return localizedRequiredText(languageCode, title, titleLocales); return localizedText(languageCode, title, titleLocales);
} }
String localizedSynopsis(String languageCode) { String localizedSynopsis(String languageCode) {
return localizedRequiredText(languageCode, synopsis, synopsisLocales); return localizedText(languageCode, synopsis, synopsisLocales);
} }
String localizedTag(String languageCode) { String localizedTag(String languageCode) {
-13
View File
@@ -18,16 +18,3 @@ String localizedText(
if (chinese.isNotEmpty) return chinese; if (chinese.isNotEmpty) return chinese;
return fallback; 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);
+7
View File
@@ -27,6 +27,9 @@ abstract final class AppColors {
static const overlaySecondary = Color(0xFFC7C7CC); static const overlaySecondary = Color(0xFFC7C7CC);
static const overlayLabel = Color(0xFFE6E6EB); static const overlayLabel = Color(0xFFE6E6EB);
/// 首页及剧集播放页视频信息文案:参考设计图的 65% 半透明白。
static const homeFeedVideoCopy = Color(0xA6FFFFFF);
/// 主操作、选中态、播放进度。旧品牌橙已全量映射为品牌红(2026-10 改版)。 /// 主操作、选中态、播放进度。旧品牌橙已全量映射为品牌红(2026-10 改版)。
static const blue = Color(0xFFF04E4E); static const blue = Color(0xFFF04E4E);
static const primary = blue; static const primary = blue;
@@ -153,6 +156,10 @@ abstract final class AppColors {
/// 列表底部「没有更多了」这类弱提示。 /// 列表底部「没有更多了」这类弱提示。
static const textHint = Color(0xFF5C5C60); static const textHint = Color(0xFF5C5C60);
static const textTabInactive = Color(0xFFB0B0B0); static const textTabInactive = Color(0xFFB0B0B0);
/// 首页顶部三项导航未选中文字,略暗于选中白色但保持清晰。
static const homeTabInactive = Color(0xFFC7C7CC);
static const textFilterIcon = Color(0xFF7A7A7E); static const textFilterIcon = Color(0xFF7A7A7E);
static const searchResultMeta = Color(0xFF9E9E9E); static const searchResultMeta = Color(0xFF9E9E9E);
+71
View File
@@ -0,0 +1,71 @@
// Author: fruit.com.cn
// 职责:固定顶部导航文字排版,以绘制缩放和颜色插值实现选中切换。
import 'package:flutter/material.dart';
import '../theme/app_colors.dart';
import '../theme/app_dimens.dart';
const double _inactiveScale = 0.9;
/// 保留最大字号、固定字重与占位,避免选中动画触发相邻标签重新布局。
class AnimatedNavLabel extends StatelessWidget {
/// [style] 指定选中态字号与颜色;字重固定,未指定时默认加粗。
const AnimatedNavLabel({
super.key,
required this.label,
required this.selected,
required this.style,
this.alignment = Alignment.bottomCenter,
this.selectionProgress,
this.inactiveColor = AppColors.textTabInactive,
});
final String label;
final bool selected;
final TextStyle style;
final Alignment alignment;
/// 未选中文字颜色;默认保持通用导航原有灰色。
final Color inactiveColor;
/// 页面拖动时直接使用连续进度,避免再叠加一层延迟动画。
final double? selectionProgress;
@override
Widget build(BuildContext context) {
final target = selected ? 1.0 : 0.0;
final reduceMotion = MediaQuery.disableAnimationsOf(context);
return Semantics(
selected: selected,
child: selectionProgress != null
? _label(selectionProgress!.clamp(0.0, 1.0))
: TweenAnimationBuilder<double>(
tween: Tween(begin: target, end: target),
duration: reduceMotion
? Duration.zero
: AppDimens.homePrimaryTabAnimationDuration,
curve: Curves.easeOut,
builder: (context, progress, _) => _label(progress),
),
);
}
Widget _label(double progress) => Transform.scale(
scale: _inactiveScale + (1 - _inactiveScale) * progress,
alignment: alignment,
child: Text(
label,
maxLines: 1,
textAlign: TextAlign.left,
style: style.copyWith(
fontWeight: style.fontWeight ?? FontWeight.w700,
letterSpacing: 0,
color: Color.lerp(
inactiveColor,
style.color ?? AppColors.textPrimary,
progress,
),
),
),
);
}
+24 -17
View File
@@ -18,6 +18,7 @@ class AppBottomNavItem {
this.selected = false, this.selected = false,
this.assetPath, this.assetPath,
this.selectedAssetPath, this.selectedAssetPath,
this.customIcon,
}); });
final Key tabKey; final Key tabKey;
@@ -25,6 +26,7 @@ class AppBottomNavItem {
final IconData selectedIcon; final IconData selectedIcon;
final String? assetPath; final String? assetPath;
final String? selectedAssetPath; final String? selectedAssetPath;
final Widget? customIcon;
final String label; final String label;
final VoidCallback onTap; final VoidCallback onTap;
final bool selected; final bool selected;
@@ -93,26 +95,31 @@ class _NavDestination extends StatelessWidget {
duration: const Duration(milliseconds: 160), duration: const Duration(milliseconds: 160),
curve: Curves.easeOut, curve: Curves.easeOut,
scale: item.selected ? 1 : 0.96, scale: item.selected ? 1 : 0.96,
child: assetPath == null child:
? Icon( item.customIcon ??
fallbackIcon, (assetPath == null
size: AppDimens.navIcon, ? Icon(
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, fallbackIcon,
size: AppDimens.navIcon, size: AppDimens.navIcon,
color: color, 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), const SizedBox(height: AppDimens.navLabelGap),
Text( Text(
+16 -2
View File
@@ -2,8 +2,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../theme/app_dimens.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
import 'app_bottom_nav.dart'; import 'app_bottom_nav.dart';
import 'root_nav_glyph.dart';
/// 客户端根导航的固定顺序。 /// 客户端根导航的固定顺序。
enum AppRootTab { enum AppRootTab {
@@ -16,6 +18,13 @@ enum AppRootTab {
} }
extension AppRootTabUi on 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) { Key get tabKey => switch (this) {
AppRootTab.home => const Key('tab-home'), AppRootTab.home => const Key('tab-home'),
AppRootTab.recommend => const Key('tab-recommend'), AppRootTab.recommend => const Key('tab-recommend'),
@@ -25,14 +34,14 @@ extension AppRootTabUi on AppRootTab {
IconData get icon => switch (this) { IconData get icon => switch (this) {
AppRootTab.home => Icons.home_outlined, AppRootTab.home => Icons.home_outlined,
AppRootTab.recommend => Icons.celebration_outlined, AppRootTab.recommend => Icons.smart_display_outlined,
AppRootTab.following => Icons.bookmark_border_rounded, AppRootTab.following => Icons.bookmark_border_rounded,
AppRootTab.mine => Icons.person_outline_rounded, AppRootTab.mine => Icons.person_outline_rounded,
}; };
IconData get selectedIcon => switch (this) { IconData get selectedIcon => switch (this) {
AppRootTab.home => Icons.home_rounded, AppRootTab.home => Icons.home_rounded,
AppRootTab.recommend => Icons.celebration_rounded, AppRootTab.recommend => Icons.smart_display_rounded,
AppRootTab.following => Icons.bookmark_rounded, AppRootTab.following => Icons.bookmark_rounded,
AppRootTab.mine => Icons.person_rounded, AppRootTab.mine => Icons.person_rounded,
}; };
@@ -71,6 +80,11 @@ class AppRootBottomNav extends StatelessWidget {
selectedIcon: tab.selectedIcon, selectedIcon: tab.selectedIcon,
label: tab.label(l10n), label: tab.label(l10n),
selected: tab == selected, selected: tab == selected,
customIcon: RootNavGlyph(
kind: tab.glyphKind,
selected: tab == selected,
size: AppDimens.navIcon,
),
onTap: () => onSelected(tab), onTap: () => onSelected(tab),
), ),
], ],
@@ -0,0 +1,86 @@
// Author: fruit.com.cn
// 职责:给默认图片加载占位叠加可暂停、局部重绘的轻量扫光。
import 'package:flutter/material.dart';
const _sweepDuration = Duration(milliseconds: 1400);
const double _bandWidthRatio = 0.65;
const _highlight = Color(0x12FFFFFF);
const _clear = Color(0x00FFFFFF);
/// 只用于等待真实图片的占位;减少动画或页面离屏时不运行扫光。
class ImagePlaceholderShimmer extends StatefulWidget {
/// 在 [child] 上方绘制不影响布局与点击区域的扫光。
const ImagePlaceholderShimmer({super.key, required this.child});
final Widget child;
@override
State<ImagePlaceholderShimmer> createState() =>
_ImagePlaceholderShimmerState();
}
class _ImagePlaceholderShimmerState extends State<ImagePlaceholderShimmer>
with SingleTickerProviderStateMixin {
late final AnimationController _controller = AnimationController(
vsync: this,
duration: _sweepDuration,
);
bool _active = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_active =
TickerMode.valuesOf(context).enabled &&
!MediaQuery.disableAnimationsOf(context);
if (_active) {
_controller.repeat();
} else {
_controller.stop();
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (!_active) return widget.child;
return RepaintBoundary(
child: ClipRect(
child: CustomPaint(
foregroundPainter: _SweepPainter(_controller),
child: widget.child,
),
),
);
}
}
class _SweepPainter extends CustomPainter {
_SweepPainter(this.progress) : super(repaint: progress);
final Animation<double> progress;
@override
void paint(Canvas canvas, Size size) {
if (size.isEmpty) return;
final bandWidth = size.width * _bandWidthRatio;
final left = -bandWidth + (size.width + bandWidth) * progress.value;
final rect = Rect.fromLTWH(left, 0, bandWidth, size.height);
final paint = Paint()
..shader = const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [_clear, _highlight, _clear],
).createShader(rect);
canvas.drawRect(rect, paint);
}
@override
bool shouldRepaint(covariant _SweepPainter oldDelegate) =>
oldDelegate.progress != progress;
}
+8 -1
View File
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import '../config/app_config.dart'; import '../config/app_config.dart';
import '../theme/app_colors.dart'; import '../theme/app_colors.dart';
import 'image_placeholder_shimmer.dart';
const int _minImageDecodePixels = 96; const int _minImageDecodePixels = 96;
const int _maxImageDecodePixels = 2048; const int _maxImageDecodePixels = 2048;
@@ -102,13 +103,19 @@ class RemoteImage extends StatelessWidget {
errorBuilder: (_, _, _) => _fallback(brand: showFallbackIcon), errorBuilder: (_, _, _) => _fallback(brand: showFallbackIcon),
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
if (wasSynchronouslyLoaded || frame != null) return child; if (wasSynchronouslyLoaded || frame != null) return child;
return _fallback(brand: showFallbackIcon); return _loadingFallback();
}, },
); );
}, },
); );
} }
Widget _loadingFallback() {
final custom = fallbackWidget;
if (custom != null) return custom;
return ImagePlaceholderShimmer(child: _fallback(brand: showFallbackIcon));
}
Widget _fallback({required bool brand}) { Widget _fallback({required bool brand}) {
final custom = fallbackWidget; final custom = fallbackWidget;
if (custom != null) return custom; if (custom != null) return custom;
+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;
}
+1 -1
View File
@@ -761,7 +761,7 @@
"homeBadgeNew": "New", "homeBadgeNew": "New",
"homeBadgeAnime": "Anime", "homeBadgeAnime": "Anime",
"emptyContent": "No content yet", "emptyContent": "No content yet",
"recommendTopTab": "Recommend", "recommendTopTab": "Featured",
"playlistTopTab": "Playlists", "playlistTopTab": "Playlists",
"allDramasTopTab": "All Dramas", "allDramasTopTab": "All Dramas",
"weeklyHitsTitle": "This Week's Hits", "weeklyHitsTitle": "This Week's Hits",
+1 -1
View File
@@ -723,7 +723,7 @@
"homeBadgeNew": "Baru", "homeBadgeNew": "Baru",
"homeBadgeAnime": "Anime", "homeBadgeAnime": "Anime",
"emptyContent": "Belum ada konten", "emptyContent": "Belum ada konten",
"recommendTopTab": "Rekomendasi", "recommendTopTab": "Unggulan",
"playlistTopTab": "Daftar Putar", "playlistTopTab": "Daftar Putar",
"allDramasTopTab": "Semua", "allDramasTopTab": "Semua",
"weeklyHitsTitle": "Hits Minggu Ini", "weeklyHitsTitle": "Hits Minggu Ini",
+1 -1
View File
@@ -3271,7 +3271,7 @@ abstract class AppLocalizations {
/// No description provided for @recommendTopTab. /// No description provided for @recommendTopTab.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Recommend'** /// **'Featured'**
String get recommendTopTab; String get recommendTopTab;
/// No description provided for @playlistTopTab. /// No description provided for @playlistTopTab.
+1 -1
View File
@@ -1711,7 +1711,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get emptyContent => 'No content yet'; String get emptyContent => 'No content yet';
@override @override
String get recommendTopTab => 'Recommend'; String get recommendTopTab => 'Featured';
@override @override
String get playlistTopTab => 'Playlists'; String get playlistTopTab => 'Playlists';
+1 -1
View File
@@ -1711,7 +1711,7 @@ class AppLocalizationsId extends AppLocalizations {
String get emptyContent => 'Belum ada konten'; String get emptyContent => 'Belum ada konten';
@override @override
String get recommendTopTab => 'Rekomendasi'; String get recommendTopTab => 'Unggulan';
@override @override
String get playlistTopTab => 'Daftar Putar'; String get playlistTopTab => 'Daftar Putar';
+1 -1
View File
@@ -1686,7 +1686,7 @@ class AppLocalizationsZh extends AppLocalizations {
String get emptyContent => '暂无内容'; String get emptyContent => '暂无内容';
@override @override
String get recommendTopTab => '推荐'; String get recommendTopTab => '精选';
@override @override
String get playlistTopTab => '剧单'; String get playlistTopTab => '剧单';
+1 -1
View File
@@ -685,7 +685,7 @@
"homeBadgeNew": "新剧", "homeBadgeNew": "新剧",
"homeBadgeAnime": "漫剧", "homeBadgeAnime": "漫剧",
"emptyContent": "暂无内容", "emptyContent": "暂无内容",
"recommendTopTab": "推荐", "recommendTopTab": "精选",
"playlistTopTab": "剧单", "playlistTopTab": "剧单",
"allDramasTopTab": "全部短剧", "allDramasTopTab": "全部短剧",
"weeklyHitsTitle": "本週爆款推薦", "weeklyHitsTitle": "本週爆款推薦",
@@ -4,7 +4,6 @@ import 'package:flutter/material.dart';
import '../../../core/data/models/drama.dart'; import '../../../core/data/models/drama.dart';
import '../../../core/formatting/error_text.dart'; import '../../../core/formatting/error_text.dart';
import '../../../core/formatting/content_locale_guard.dart';
import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_colors.dart';
import '../../../core/widgets/empty_state.dart'; import '../../../core/widgets/empty_state.dart';
import '../../../l10n/app_localizations.dart'; import '../../../l10n/app_localizations.dart';
@@ -34,18 +33,10 @@ class DiscoverSearchResults extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
final visibleResults = visibleDramasForLocale( if (!loading && error == null && results.isNotEmpty) {
results,
Localizations.localeOf(context).languageCode,
);
if (!loading && error == null && visibleResults.isNotEmpty) {
return SliverPadding( return SliverPadding(
padding: _topPadding, padding: _topPadding,
sliver: SliverDramaGrid( sliver: SliverDramaGrid(l10n: l10n, dramas: results, onOpen: onOpen),
l10n: l10n,
dramas: visibleResults,
onOpen: onOpen,
),
); );
} }
return SliverPadding( return SliverPadding(
@@ -1,22 +1,18 @@
// Author: fruit.com.cn // Author: fruit.com.cn
// 职责:追剧收藏网格、分页和编辑选择,不承载顶部续播或重复区块标题。
import 'dart:async'; import 'dart:async';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../core/data/models/watch_record.dart';
import '../../core/data/services/favorite_controller.dart'; import '../../core/data/services/favorite_controller.dart';
import '../../core/data/services/watch_records_controller.dart';
import '../../core/formatting/error_text.dart'; import '../../core/formatting/error_text.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_dimens.dart';
import '../../core/widgets/empty_state.dart'; import '../../core/widgets/empty_state.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
import '../player/open_player.dart'; import '../player/open_player.dart';
import 'following_grid_card.dart'; import 'following_grid_card.dart';
import 'following_paged_grid.dart'; import 'following_paged_grid.dart';
import 'widgets/continue_watching_banner.dart';
/// 追剧编辑中的选中状态。 /// 追剧编辑中的选中状态。
class FollowingFavoriteSelection { class FollowingFavoriteSelection {
@@ -36,19 +32,15 @@ class FollowingFavoritesGrid extends StatelessWidget {
const FollowingFavoritesGrid({ const FollowingFavoritesGrid({
super.key, super.key,
required this.controller, required this.controller,
required this.history,
required this.selection, required this.selection,
required this.scrollController, required this.scrollController,
required this.onGoHome, required this.onGoHome,
required this.onContinueRecord,
}); });
final FavoriteController controller; final FavoriteController controller;
final WatchRecordsController history;
final FollowingFavoriteSelection selection; final FollowingFavoriteSelection selection;
final ScrollController scrollController; final ScrollController scrollController;
final VoidCallback onGoHome; final VoidCallback onGoHome;
final ValueChanged<WatchRecord> onContinueRecord;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -59,7 +51,7 @@ class FollowingFavoritesGrid extends StatelessWidget {
valueListenable: selection.selected, valueListenable: selection.selected,
builder: (context, picked, _) { builder: (context, picked, _) {
return ListenableBuilder( return ListenableBuilder(
listenable: Listenable.merge([controller, history]), listenable: controller,
builder: (context, _) => _grid(context, isEditing, picked), builder: (context, _) => _grid(context, isEditing, picked),
); );
}, },
@@ -89,7 +81,6 @@ class FollowingFavoritesGrid extends StatelessWidget {
onRefresh: controller.load, onRefresh: controller.load,
onLoadMore: controller.loadMore, onLoadMore: controller.loadMore,
itemCount: controller.cards.length + extra, itemCount: controller.cards.length + extra,
topSlivers: _topSlivers(context),
empty: EmptyState( empty: EmptyState(
key: const Key('following-favorites-empty'), key: const Key('following-favorites-empty'),
icon: Icons.bookmark_border_rounded, icon: Icons.bookmark_border_rounded,
@@ -99,68 +90,6 @@ class FollowingFavoritesGrid extends StatelessWidget {
); );
} }
List<Widget> _topSlivers(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final continueRecord = _continueRecord();
return [
if (continueRecord != null)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(
AppDimens.feedPageHorizontal,
4,
AppDimens.feedPageHorizontal,
0,
),
child: ContinueWatchingBanner(
record: continueRecord,
languageCode: Localizations.localeOf(context).languageCode,
onTap: () => onContinueRecord(continueRecord),
),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(
AppDimens.feedPageHorizontal,
AppDimens.followingSectionTopGap,
AppDimens.feedPageHorizontal,
AppDimens.followingSectionBottomGap,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
l10n.favoriteDramas,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: AppDimens.followingSectionTitleFont,
fontWeight: FontWeight.w400,
),
),
const Spacer(),
Padding(
padding: const EdgeInsets.only(bottom: 2),
child: Text(
l10n.totalDramasCount(controller.total),
style: const TextStyle(
color: AppColors.textTabInactive,
fontSize: AppDimens.followingSectionCountFont,
),
),
),
],
),
),
),
];
}
/// 最近一条观看记录(records 已按最近观看排序,与观看历史页首条一致)。
WatchRecord? _continueRecord() {
return history.records.isEmpty ? null : history.records.first;
}
Widget _item( Widget _item(
BuildContext context, BuildContext context,
int index, int index,
+2 -5
View File
@@ -1,4 +1,5 @@
// Author: fruit.com.cn // Author: fruit.com.cn
// 职责:追剧收藏与观看历史切换、数据刷新、管理和续播入口。
import 'dart:async'; import 'dart:async';
@@ -39,7 +40,7 @@ class FollowingPage extends StatefulWidget {
final FavoriteController favoriteController; final FavoriteController favoriteController;
final int historyRequest; final int historyRequest;
/// 当前是否为首页选中的 tab;重新可见时刷新观看历史,保证横幅是最新进度。 /// 当前是否为首页选中的 tab;重新可见时刷新观看历史,保证记录是最新进度。
final bool visible; final bool visible;
@override @override
@@ -157,7 +158,6 @@ class _FollowingPageState extends State<FollowingPage> {
FollowingKeepAliveTab( FollowingKeepAliveTab(
child: FollowingFavoritesGrid( child: FollowingFavoritesGrid(
controller: widget.favoriteController, controller: widget.favoriteController,
history: _history,
selection: FollowingFavoriteSelection( selection: FollowingFavoriteSelection(
editing: _editing, editing: _editing,
selected: _selected, selected: _selected,
@@ -165,9 +165,6 @@ class _FollowingPageState extends State<FollowingPage> {
), ),
scrollController: _favoriteScroll, scrollController: _favoriteScroll,
onGoHome: widget.onGoHome, onGoHome: widget.onGoHome,
onContinueRecord: (record) => unawaited(
_openHistory(context, _history, record),
),
), ),
), ),
FollowingKeepAliveTab( FollowingKeepAliveTab(
@@ -7,7 +7,6 @@ import 'package:provider/provider.dart';
import '../../../core/data/models/drama.dart'; import '../../../core/data/models/drama.dart';
import '../../../core/data/services/category_controller.dart'; import '../../../core/data/services/category_controller.dart';
import '../../../core/formatting/content_locale_guard.dart';
import '../../../core/theme/app_dimens.dart'; import '../../../core/theme/app_dimens.dart';
import '../../../core/widgets/app_pull_to_refresh.dart'; import '../../../core/widgets/app_pull_to_refresh.dart';
import '../../../core/widgets/drama_grid_skeleton.dart'; import '../../../core/widgets/drama_grid_skeleton.dart';
@@ -78,10 +77,7 @@ class _HomeCategoryDramasState extends State<HomeCategoryDramas> {
final controller = context.watch<CategoryController>(); final controller = context.watch<CategoryController>();
final categoryId = widget.lockedCategoryId ?? ''; final categoryId = widget.lockedCategoryId ?? '';
final slot = categoryId.isEmpty ? null : controller.slotOf(categoryId); final slot = categoryId.isEmpty ? null : controller.slotOf(categoryId);
final dramas = visibleDramasForLocale( final dramas = slot?.dramas ?? const <Drama>[];
slot?.dramas ?? const <Drama>[],
Localizations.localeOf(context).languageCode,
);
return Column( return Column(
children: [ children: [
Expanded( 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/category_controller.dart';
import '../../../core/data/services/home_feed_controller.dart'; import '../../../core/data/services/home_feed_controller.dart';
import '../../../core/data/services/wallet_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_colors.dart';
import '../../../core/theme/app_dimens.dart'; import '../../../core/theme/app_dimens.dart';
import '../../../core/widgets/app_pull_to_refresh.dart'; import '../../../core/widgets/app_pull_to_refresh.dart';
@@ -195,10 +194,7 @@ class _HomeFeedGridState extends State<HomeFeedGrid> {
mainAxisSpacing: AppDimens.homeGridRowGap, mainAxisSpacing: AppDimens.homeGridRowGap,
); );
} }
final dramas = _dramasFor( final dramas = _dramasFor(category);
category,
Localizations.localeOf(context).languageCode,
);
if (widget.feedController.loadingFirstPage && dramas.isEmpty) { if (widget.feedController.loadingFirstPage && dramas.isEmpty) {
return const DramaGridSkeleton( return const DramaGridSkeleton(
key: Key('home-grid-skeleton'), key: Key('home-grid-skeleton'),
@@ -328,14 +324,8 @@ class _HomeFeedGridState extends State<HomeFeedGrid> {
]; ];
} }
List<Drama> _dramasFor( List<Drama> _dramasFor(HomeGridCategoryShortcut category) {
HomeGridCategoryShortcut category, final dramas = widget.feedController.dramas;
String languageCode,
) {
final dramas = visibleDramasForLocale(
widget.feedController.dramas,
languageCode,
);
final categoryName = category.categoryName.toLowerCase(); final categoryName = category.categoryName.toLowerCase();
if (categoryName.isEmpty) return dramas; if (categoryName.isEmpty) return dramas;
final matched = dramas final matched = dramas
+8 -12
View File
@@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
import '../../../core/formatting/count_format.dart'; import '../../../core/formatting/count_format.dart';
import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart'; import '../../../core/theme/app_dimens.dart';
import '../../../core/widgets/animated_nav_label.dart';
import '../../../l10n/app_localizations.dart'; import '../../../l10n/app_localizations.dart';
enum HomePrimaryTab { enum HomePrimaryTab {
@@ -323,21 +324,16 @@ class _CategoryBarState extends State<_CategoryBar> {
height: AppDimens.homePrimaryRowHeight, height: AppDimens.homePrimaryRowHeight,
child: Align( child: Align(
alignment: Alignment.bottomLeft, alignment: Alignment.bottomLeft,
child: AnimatedDefaultTextStyle( child: AnimatedNavLabel(
key: Key('home-primary-label-${category.categoryId}'), key: Key('home-primary-label-${category.categoryId}'),
duration: AppDimens.homePrimaryTabAnimationDuration, label: category.label,
curve: Curves.easeOut, selected: selected,
style: TextStyle( alignment: Alignment.bottomLeft,
color: selected style: const TextStyle(
? AppColors.navSelected color: AppColors.navSelected,
: AppColors.textTabInactive, fontSize: AppDimens.homePrimaryTabSelectedFont,
fontSize: selected
? AppDimens.homePrimaryTabSelectedFont
: AppDimens.homePrimaryTabFont,
fontWeight: selected ? FontWeight.w700 : FontWeight.w400,
letterSpacing: 0, letterSpacing: 0,
), ),
child: Text(category.label, textAlign: TextAlign.left),
), ),
), ),
), ),
@@ -0,0 +1,172 @@
// 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(
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,
this.textColor = AppColors.homeFeedVideoCopy,
});
/// 按当前语言获取的简介正文。
final String synopsis;
/// 当前页面的组件标识前缀。
final String keyPrefix;
/// 展开后附加的声明等内容。
final Widget? expandedFooter;
/// 简介正文颜色;默认使用视频信息区统一的半透明浅灰白。
final Color textColor;
@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, _bodyStyle);
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');
TextStyle get _bodyStyle => _synopsisStyle.copyWith(color: widget.textColor);
Widget _body() {
final style = _bodyStyle;
final text = Text.rich(
TextSpan(text: widget.synopsis, style: style),
key: _expanded ? _key('expanded') : Key('${widget.keyPrefix}-synopsis'),
style: style,
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.homeFeedVideoCopy,
fontSize: _tagFont,
fontWeight: FontWeight.w500,
height: _metadataLineHeight,
),
);
}
@@ -1,4 +1,5 @@
// Author: fruit.com.cn // Author: fruit.com.cn
// 职责:推荐视频浮层的底部信息、互动栏、进度条与手势协调。
import 'dart:async'; import 'dart:async';
@@ -26,11 +27,15 @@ const double _progressHeight = 2;
const double _progressHitHeight = 16; const double _progressHitHeight = 16;
const double _pageHorizontal = 20; const double _pageHorizontal = 20;
const double _railRight = 14; const double _railRight = 14;
const double _railShiftRight = 8;
const double _railGap = 12; const double _railGap = 12;
const double _railWidth = 56;
const double _infoWatchAllGap = 2;
// 进度线贴着屏幕底部(线心距底 4pt),观看条底边只留 8pt,视觉上就是线上一根条。 // 进度线贴着屏幕底部(线心距底 4pt),观看条底边只留 8pt,视觉上就是线上一根条。
const double _watchAllBottomInset = 8; const double _watchAllBottomInset = 8;
// 信息面板底边直接贴住观看条顶沿,中间不留缝。 // 简介与观看条留出明确间距,展开后仍保留相同的底部留白。
const double _bottomInfoInset = _watchAllBottomInset + homeFeedWatchAllHeight; const double _bottomInfoInset =
_watchAllBottomInset + homeFeedWatchAllHeight + _infoWatchAllGap;
const double _railBottomOffset = 40; const double _railBottomOffset = 40;
const Duration _overlayFadeDuration = Duration(milliseconds: 120); const Duration _overlayFadeDuration = Duration(milliseconds: 120);
const List<Shadow> _boostTextShadows = [ const List<Shadow> _boostTextShadows = [
@@ -169,7 +174,7 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
// 否则拖拽 seek 的起点会被观看条吞掉。两块的可见区域(文字/进度线)互不遮挡。 // 否则拖拽 seek 的起点会被观看条吞掉。两块的可见区域(文字/进度线)互不遮挡。
Positioned( Positioned(
left: _pageHorizontal, left: _pageHorizontal,
right: _pageHorizontal, right: _railRight + _railGap + _railWidth,
bottom: _watchAllBottomInset, bottom: _watchAllBottomInset,
child: AnimatedOpacity( child: AnimatedOpacity(
duration: _overlayFadeDuration, duration: _overlayFadeDuration,
@@ -224,9 +229,17 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
children: [ children: [
Expanded(child: _bottomInfo()), Expanded(child: _bottomInfo()),
const SizedBox(width: _railGap), const SizedBox(width: _railGap),
Padding( SizedBox(
padding: const EdgeInsets.only(bottom: _railBottomOffset), width: _railWidth,
child: _rail(), child: Padding(
padding: const EdgeInsets.only(
bottom: _railBottomOffset,
),
child: Transform.translate(
offset: const Offset(_railShiftRight, 0),
child: _rail(),
),
),
), ),
], ],
), ),
@@ -1,32 +1,47 @@
// Author: fruit.com.cn // Author: fruit.com.cn
// 职责:推荐视频底部标题、标签、集数、简介与观看全集入口。
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import '../../../core/data/models/drama.dart'; import '../../../core/data/models/drama.dart';
import '../../../core/data/models/locale_text.dart';
import '../../../core/formatting/localized_content.dart'; import '../../../core/formatting/localized_content.dart';
import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_colors.dart';
import '../../../l10n/app_localizations.dart'; import '../../../l10n/app_localizations.dart';
import '../../home/widgets/home_drama_classification.dart'; import 'expandable_synopsis.dart';
import 'interaction_rail.dart'; import 'interaction_rail.dart';
const double _seriesFont = 17; part 'home_feed_info_tags.dart';
const double _tagFont = 12;
const double _synopsisFont = 14; const double _seriesFont = 18;
const double _tagFont = 13;
const double _watchAllHeight = 38; 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 按页面边距对齐,内部不再重复内缩。 /// 观看条内部左右留白与圆角:外沿由 overlay 按页面边距对齐,内部不再重复内缩。
const double _watchAllInset = 12; 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 够分层。 /// 「观看完整剧集」渐变按钮高度,overlay 用它安排底部避让。
const double _blockGap = 4;
const List<Shadow> _textShadows = [
Shadow(color: AppColors.shadow, blurRadius: 6, offset: Offset(0, 1)),
];
final RegExp _tagSplitter = RegExp(r'[,/、·]');
/// 通栏「观看完整剧集」半透明白底条高度,overlay 用它安排底部避让。
const double homeFeedWatchAllHeight = _watchAllHeight; const double homeFeedWatchAllHeight = _watchAllHeight;
/// 播放量紧凑展示(跟随语言):zh 20万 / en 200K / id 200 rb。 /// 播放量紧凑展示(跟随语言):zh 20万 / en 200K / id 200 rb。
@@ -56,15 +71,13 @@ class HomeFeedInfoPanel extends StatefulWidget {
} }
class _HomeFeedInfoPanelState extends State<HomeFeedInfoPanel> { class _HomeFeedInfoPanelState extends State<HomeFeedInfoPanel> {
bool _synopsisExpanded = false;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
final dramaTitle = localizedDramaTitle(l10n, widget.drama); final dramaTitle = localizedDramaTitle(l10n, widget.drama);
final synopsis = localizedDramaSynopsis(l10n, widget.drama).trim(); final synopsis = localizedDramaSynopsis(l10n, widget.drama).trim();
final genres = _genreValues(Localizations.localeOf(context).languageCode); final types = _typeLabels(Localizations.localeOf(context).languageCode);
final tags = _tagChips(l10n, genres); final tags = _tagChips(types);
return Column( return Column(
key: const Key('home-feed-info-panel'), key: const Key('home-feed-info-panel'),
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -80,306 +93,109 @@ class _HomeFeedInfoPanelState extends State<HomeFeedInfoPanel> {
), ),
onTap: widget.onOpenSearch!, onTap: widget.onOpenSearch!,
), ),
const SizedBox(height: 8), const SizedBox(height: _hotSearchBottomGap),
],
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),
], ],
_title(dramaTitle),
const SizedBox(height: _titleMetadataGap),
_metadata(l10n, tags),
if (synopsis.isNotEmpty) ...[ if (synopsis.isNotEmpty) ...[
const SizedBox(height: _blockGap), const SizedBox(height: _metadataSynopsisGap),
_synopsis(l10n, synopsis), ExpandableSynopsis(
key: ValueKey(widget.drama.id),
synopsis: synopsis,
textColor: AppColors.homeFeedVideoCopy,
),
], ],
], ],
); );
} }
List<Widget> _tagChips(AppLocalizations l10n, List<String> genres) { Widget _title(String dramaTitle) => GestureDetector(
return [ key: const Key('home-feed-series-name'),
if (isHotDrama(widget.drama)) behavior: HitTestBehavior.opaque,
_TagChip( onTap: widget.onOpenFullPlayer,
key: const Key('home-feed-hot-badge'), child: Row(
label: l10n.homeBadgeHot, mainAxisSize: MainAxisSize.min,
filled: true, 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) const SizedBox(width: 2),
_TagChip(key: Key('home-feed-tag-$genre'), label: genre), 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) { Widget _metadata(AppLocalizations l10n, List<Widget> tags) {
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);
}
return LayoutBuilder( return LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final toggle = _synopsisToggle( final episode = Text(
key: const Key('home-feed-synopsis-more'), widget.drama.episodeCount > 0
label: l10n.expandSynopsis, ? l10n.episodeOfTotal(
padding: const EdgeInsets.only(left: 6), widget.episode.number,
); widget.drama.episodeCount,
final painter = TextPainter( )
text: span, : l10n.feedEpisodePrefix(widget.episode.number),
key: const Key('home-feed-episode-meta'),
maxLines: 1, maxLines: 1,
textDirection: Directionality.of(context), overflow: TextOverflow.ellipsis,
textScaler: MediaQuery.textScalerOf(context), strutStyle: _metadataStrutStyle,
)..layout(maxWidth: constraints.maxWidth); style: const TextStyle(
if (!painter.didExceedMaxLines) { color: AppColors.textPrimary,
return Text.rich( fontSize: _episodeFont,
span, height: _metadataLineHeight,
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,
],
), ),
); );
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( List<String> _typeLabels(String languageCode) => widget.drama.types
InlineSpan span, .take(_maximumTags)
TextStyle style, .map((type) => localizedText(languageCode, '', type).trim())
AppLocalizations l10n, .where((label) => label.isNotEmpty)
) { .toList(growable: false);
// 「收起」内联在简介最后一行末尾,避免独占一行导致多行简介与进度条间
// 出现大片空白;垂直方向只留少量触控留白(仅撑高最后一行)。
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);
}
} }
/// 热搜榜降级胶囊:🔍 共 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 { class HomeFeedWatchAllBar extends StatelessWidget {
const HomeFeedWatchAllBar({ const HomeFeedWatchAllBar({
super.key, super.key,
@@ -395,49 +211,43 @@ class HomeFeedWatchAllBar extends StatelessWidget {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
return ClipRRect( return ClipRRect(
borderRadius: BorderRadius.circular(_watchAllRadius), borderRadius: BorderRadius.circular(_watchAllRadius),
child: Material( child: DecoratedBox(
key: const Key('home-feed-full-series-bg'), key: const Key('home-feed-full-series-gradient'),
color: AppColors.textPrimary.withValues(alpha: 0.15), decoration: const BoxDecoration(gradient: _watchAllGradient),
child: InkWell( child: Material(
key: const Key('home-feed-full-series'), key: const Key('home-feed-full-series-bg'),
onTap: onTap, color: AppColors.transparent,
child: SizedBox( child: InkWell(
height: _watchAllHeight, key: const Key('home-feed-full-series'),
child: Padding( onTap: onTap,
padding: const EdgeInsets.symmetric(horizontal: _watchAllInset), child: _content(l10n),
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,
),
],
),
),
), ),
), ),
), ),
); );
} }
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 { class HomeFeedActionRail extends StatelessWidget {
@@ -468,7 +278,7 @@ class HomeFeedActionRail extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
return InteractionRail( return InteractionRail(
itemGap: 12, itemGap: interactionRailCompactGap,
buttonSize: 48, buttonSize: 48,
iconSize: 36, iconSize: 36,
countFontSize: 15, countFontSize: 15,
@@ -481,6 +291,7 @@ class HomeFeedActionRail extends StatelessWidget {
count: favoriteCount, count: favoriteCount,
countKey: const Key('rail-save-count'), countKey: const Key('rail-save-count'),
selected: saved, selected: saved,
animateTap: true,
selectedColor: AppColors.brandRed, selectedColor: AppColors.brandRed,
onTap: onSave, onTap: onSave,
), ),
@@ -499,6 +310,7 @@ class HomeFeedActionRail extends StatelessWidget {
count: likeCount, count: likeCount,
countKey: const Key('rail-like-count'), countKey: const Key('rail-like-count'),
selected: liked, selected: liked,
animateTap: true,
selectedColor: AppColors.brandRed, selectedColor: AppColors.brandRed,
onTap: onLike, onTap: onLike,
), ),
+155 -37
View File
@@ -1,15 +1,44 @@
// Author: fruit.com.cn // Author: fruit.com.cn
// 职责:播放互动栏的图标、计数与不影响触控区域的点击反馈。
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../../core/formatting/count_format.dart'; import '../../../core/formatting/count_format.dart';
import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_colors.dart';
import 'interaction_rail_burst.dart';
const double _defaultButtonSize = 44; const double _defaultButtonSize = 44;
const double _defaultIconSize = 32; const double _defaultIconSize = 32;
const double _defaultCountFont = 12; const double _defaultCountFont = 12;
const double _defaultIconCountGap = 2; const double _defaultIconCountGap = 2;
const double _defaultItemGap = 24; 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 { class InteractionRailItem {
@@ -24,6 +53,7 @@ class InteractionRailItem {
this.selected = false, this.selected = false,
this.selectedColor = AppColors.blue, this.selectedColor = AppColors.blue,
this.mirrorIcon = false, this.mirrorIcon = false,
this.animateTap = false,
}) : assert((asset == null) != (icon == null)); }) : assert((asset == null) != (icon == null));
final Key key; final Key key;
@@ -38,6 +68,9 @@ class InteractionRailItem {
/// 水平镜像图标(分享弯箭头用 reply 镜像得到)。 /// 水平镜像图标(分享弯箭头用 reply 镜像得到)。
final bool mirrorIcon; final bool mirrorIcon;
/// 选中放大、取消缩小并显示周围纹路,计数和触控范围不参与缩放。
final bool animateTap;
} }
/// 播放页与首页 feed 共用的右侧互动栏:收藏/评论/喜欢/分享。 /// 播放页与首页 feed 共用的右侧互动栏:收藏/评论/喜欢/分享。
@@ -79,7 +112,7 @@ class InteractionRail extends StatelessWidget {
} }
} }
class _RailButton extends StatelessWidget { class _RailButton extends StatefulWidget {
const _RailButton({ const _RailButton({
required this.item, required this.item,
required this.buttonSize, required this.buttonSize,
@@ -94,50 +127,135 @@ class _RailButton extends StatelessWidget {
final double countFontSize; final double countFontSize;
final double iconCountGap; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final tint = item.selected ? item.selectedColor : AppColors.onPrimary;
return Column( return Column(
key: item.key, key: widget.item.key,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
SizedBox.square( SizedBox.square(
dimension: buttonSize, dimension: widget.buttonSize,
child: IconButton( child: widget.item.animateTap ? _animatedButton() : _button(_icon()),
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),
],
),
), ),
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;
}
@@ -3,8 +3,19 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../../core/theme/app_colors.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 { class PlayerLoadingOverlay extends StatelessWidget {
const PlayerLoadingOverlay({ const PlayerLoadingOverlay({
super.key, super.key,
@@ -19,27 +30,124 @@ class PlayerLoadingOverlay extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AnimatedOpacity( return AnimatedOpacity(
opacity: opacity, opacity: opacity,
duration: const Duration(milliseconds: 140), duration: _fadeDuration,
curve: Curves.easeOut, curve: Curves.easeOut,
onEnd: onFadeEnd, onEnd: onFadeEnd,
child: Container( child: Container(
key: const Key('player-loading-surface'), key: const Key('player-loading-surface'),
width: AppDimens.refreshIndicatorSize, width: _surfaceSize.width,
height: AppDimens.refreshIndicatorSize, height: _surfaceSize.height,
decoration: BoxDecoration(
color: AppColors.refreshIndicatorSurface,
borderRadius: BorderRadius.circular(AppDimens.refreshIndicatorRadius),
),
alignment: Alignment.center, alignment: Alignment.center,
child: const SizedBox.square( child: const _YmbLoadingMark(),
dimension: AppDimens.refreshSpinnerSize,
child: CircularProgressIndicator(
key: Key('player-entry-loading'),
color: Colors.white,
strokeWidth: AppDimens.refreshSpinnerStroke,
),
),
), ),
); );
} }
} }
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();
}
+5 -3
View File
@@ -11,6 +11,7 @@ import '../../../core/formatting/localized_content.dart';
import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart'; import '../../../core/theme/app_dimens.dart';
import '../../../l10n/app_localizations.dart'; import '../../../l10n/app_localizations.dart';
import 'expandable_synopsis.dart';
import 'player_center_toggle.dart'; import 'player_center_toggle.dart';
import 'interaction_rail.dart'; import 'interaction_rail.dart';
import 'player_overlay_models.dart'; import 'player_overlay_models.dart';
@@ -178,7 +179,7 @@ class PlayerPlaybackOverlay extends StatelessWidget {
opacity: scrubbing ? 0 : 1, opacity: scrubbing ? 0 : 1,
child: InteractionRail( child: InteractionRail(
key: const Key('player-interaction-rail'), key: const Key('player-interaction-rail'),
itemGap: 12, itemGap: interactionRailCompactGap,
buttonSize: 48, buttonSize: 48,
iconSize: 36, iconSize: 36,
countFontSize: 15, countFontSize: 15,
@@ -190,6 +191,7 @@ class PlayerPlaybackOverlay extends StatelessWidget {
count: data.favoriteCount, count: data.favoriteCount,
countKey: const Key('player-rail-save-count'), countKey: const Key('player-rail-save-count'),
selected: data.saved, selected: data.saved,
animateTap: true,
onTap: actions.onSave, onTap: actions.onSave,
), ),
InteractionRailItem( InteractionRailItem(
@@ -199,6 +201,7 @@ class PlayerPlaybackOverlay extends StatelessWidget {
count: data.likeCount, count: data.likeCount,
countKey: const Key('player-rail-like-count'), countKey: const Key('player-rail-like-count'),
selected: data.liked, selected: data.liked,
animateTap: true,
selectedColor: AppColors.brandRed, selectedColor: AppColors.brandRed,
onTap: actions.onLike, onTap: actions.onLike,
), ),
@@ -300,7 +303,6 @@ class PlayerPlaybackOverlay extends StatelessWidget {
key: const Key('player-episode-synopsis'), key: const Key('player-episode-synopsis'),
l10n: data.l10n, l10n: data.l10n,
drama: data.drama, drama: data.drama,
episodeNumber: data.episode.number,
synopsis: synopsis, synopsis: synopsis,
authorNote: data.authorNote, authorNote: data.authorNote,
), ),
@@ -412,7 +414,7 @@ class PlayerPlaybackOverlay extends StatelessWidget {
style: const TextStyle( style: const TextStyle(
color: AppColors.onPrimary, color: AppColors.onPrimary,
fontSize: 13.5, fontSize: 13.5,
fontWeight: FontWeight.w800, fontWeight: FontWeight.w400,
shadows: _playerTextShadows, shadows: _playerTextShadows,
), ),
), ),
@@ -118,14 +118,12 @@ class _EpisodeSynopsis extends StatefulWidget {
super.key, super.key,
required this.l10n, required this.l10n,
required this.drama, required this.drama,
required this.episodeNumber,
required this.synopsis, required this.synopsis,
required this.authorNote, required this.authorNote,
}); });
final AppLocalizations l10n; final AppLocalizations l10n;
final Drama drama; final Drama drama;
final int episodeNumber;
final String synopsis; final String synopsis;
final String authorNote; final String authorNote;
@@ -134,26 +132,13 @@ class _EpisodeSynopsis extends StatefulWidget {
} }
class _EpisodeSynopsisState extends State<_EpisodeSynopsis> { class _EpisodeSynopsisState extends State<_EpisodeSynopsis> {
bool _expanded = false;
String? _fullSynopsis; String? _fullSynopsis;
int _detailRequest = 0; int _detailRequest = 0;
bool _detailRequested = false; bool _detailRequested = false;
/// 「第 n 集」、分隔符与简介正文共用同一套白字规格。 static const int _authorNoteLines = 2;
static const TextStyle _lineStyle = TextStyle( static const double _authorNoteGap = 4;
color: AppColors.onPrimary, static const double _authorNoteFont = 10;
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;
String get _synopsis => _fullSynopsis ?? widget.synopsis; String get _synopsis => _fullSynopsis ?? widget.synopsis;
@@ -173,7 +158,6 @@ class _EpisodeSynopsisState extends State<_EpisodeSynopsis> {
return; return;
} }
_fullSynopsis = null; _fullSynopsis = null;
_expanded = false;
_detailRequested = !widget.drama.detailLoaded; _detailRequested = !widget.drama.detailLoaded;
if (_detailRequested) unawaited(_loadFullSynopsis()); if (_detailRequested) unawaited(_loadFullSynopsis());
} }
@@ -200,123 +184,26 @@ class _EpisodeSynopsisState extends State<_EpisodeSynopsis> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return LayoutBuilder( return ExpandableSynopsis(
builder: (context, constraints) => _block(constraints.maxWidth), key: ValueKey(widget.drama.id),
keyPrefix: 'player',
synopsis: _synopsis,
expandedFooter: widget.authorNote.isEmpty ? null : _authorNote(),
); );
} }
/// 与首页推荐 feed 的简介展开/收起保持一致: Widget _authorNote() => Padding(
/// 折叠单行省略 + 行尾「展开」(整行可点);展开后内联「收起」。 padding: const EdgeInsets.only(top: _authorNoteGap),
Widget _block(double maxWidth) { child: Text(
final label = widget.l10n.episodeLabel(widget.episodeNumber); widget.authorNote,
final span = TextSpan( key: const Key('player-author-note'),
style: _lineStyle, maxLines: _authorNoteLines,
children: [ overflow: TextOverflow.ellipsis,
TextSpan(text: label, style: _lineStyle), style: const TextStyle(
_separatorSpan(), color: AppColors.playerMuted,
TextSpan(text: _synopsis, style: _lineStyle), fontSize: _authorNoteFont,
], shadows: _playerTextShadows,
);
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 _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,52 @@
// Author: fruit.com.cn
// 职责:协调首页三页的点击动画、最终选中目标和手势中断。
import 'package:flutter/widgets.dart';
const _transitionDuration = Duration(milliseconds: 220);
/// 页面移动时保留用户最终目标,防止跨页动画途中的页面抢占高亮。
class HomeTabMotionController extends PageController {
/// [onSettled] 在动画结束或被手势中断时同步实际停留页面。
HomeTabMotionController({required this.onSettled});
final ValueChanged<int> onSettled;
int? _target;
int _revision = 0;
bool _disposed = false;
/// 供导航绘制连续位置;布局尚未建立时使用初始页。
double get pagePosition => hasClients && position.hasContentDimensions
? page ?? initialPage.toDouble()
: initialPage.toDouble();
/// 点击跨页过程中只接受最终目标;拖动和稳定状态接受实际页。
bool acceptsSelection(int index) => _target == null || _target == index;
/// 点击导航时平滑移动;系统减少动画时直接到位。
Future<void> selectPage(int index, {required bool reduceMotion}) async {
final revision = ++_revision;
_target = index;
if (!hasClients) {
_target = null;
return;
}
if (reduceMotion) {
jumpToPage(index);
} else {
await animateToPage(
index,
duration: _transitionDuration,
curve: Curves.easeOut,
);
}
if (_disposed || revision != _revision) return;
_target = null;
if (hasClients) onSettled(pagePosition.round());
}
@override
void dispose() {
_disposed = true;
super.dispose();
}
}
@@ -9,7 +9,6 @@ import '../../core/data/models/drama.dart';
import '../../core/data/models/home_feed.dart'; import '../../core/data/models/home_feed.dart';
import '../../core/data/remote/catalog_api.dart'; import '../../core/data/remote/catalog_api.dart';
import '../../core/data/services/category_controller.dart'; import '../../core/data/services/category_controller.dart';
import '../../core/formatting/content_locale_guard.dart';
import '../../core/theme/app_colors.dart'; import '../../core/theme/app_colors.dart';
import '../../core/widgets/app_pull_to_refresh.dart'; import '../../core/widgets/app_pull_to_refresh.dart';
import '../../core/widgets/empty_state.dart'; import '../../core/widgets/empty_state.dart';
@@ -285,10 +284,7 @@ class _ChannelFeedState extends State<_ChannelFeed>
super.build(context); super.build(context);
final categories = context.watch<CategoryController>(); final categories = context.watch<CategoryController>();
final slot = categories.slotOf(widget.categoryId); final slot = categories.slotOf(widget.categoryId);
final dramas = visibleDramasForLocale( final dramas = slot.dramas;
slot.dramas,
Localizations.localeOf(context).languageCode,
);
final showCarousel = widget.isFeatured && widget.banners.isNotEmpty; final showCarousel = widget.isFeatured && widget.banners.isNotEmpty;
final bottomInset = MediaQuery.of(context).padding.bottom; final bottomInset = MediaQuery.of(context).padding.bottom;
+24 -124
View File
@@ -13,19 +13,21 @@ import '../../core/data/services/download_controller.dart';
import '../../core/data/services/home_feed_controller.dart'; import '../../core/data/services/home_feed_controller.dart';
import '../../core/data/services/playlist_controller.dart'; import '../../core/data/services/playlist_controller.dart';
import '../../core/data/services/wallet_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_colors.dart';
import '../../core/widgets/empty_state.dart'; import '../../core/widgets/empty_state.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
import '../home/home_feed_channel.dart'; import '../home/home_feed_channel.dart';
import '../player/open_player.dart'; import '../player/open_player.dart';
import '../player/player_view.dart'; import '../player/player_view.dart';
import 'home_tab_motion_controller.dart';
import 'recommend_types.dart'; import 'recommend_types.dart';
import 'widgets/all_dramas_filter_grid.dart'; import 'widgets/all_dramas_filter_grid.dart';
import 'widgets/playlist_detail_page.dart'; import 'widgets/playlist_detail_page.dart';
import 'widgets/recommend_sections_view.dart'; import 'widgets/recommend_sections_view.dart';
import 'widgets/recommend_top_nav.dart'; import 'widgets/recommend_top_nav.dart';
part 'recommend_page_parts.dart';
class RecommendPage extends StatefulWidget { class RecommendPage extends StatefulWidget {
const RecommendPage({ const RecommendPage({
super.key, super.key,
@@ -60,7 +62,9 @@ class _RecommendPageState extends State<RecommendPage>
static const int _maxRecommendRetries = 3; static const int _maxRecommendRetries = 3;
RecommendInnerTab _tab = RecommendInnerTab.feed; RecommendInnerTab _tab = RecommendInnerTab.feed;
final PageController _pageController = PageController(); late final _pageController = HomeTabMotionController(
onSettled: _onPageChanged,
);
int _scrubCancelRequest = 0; int _scrubCancelRequest = 0;
bool _recommendDataRefreshScheduled = false; bool _recommendDataRefreshScheduled = false;
bool _recommendRefreshInFlight = false; bool _recommendRefreshInFlight = false;
@@ -274,12 +278,16 @@ class _RecommendPageState extends State<RecommendPage>
right: 0, right: 0,
child: SafeArea( child: SafeArea(
bottom: false, bottom: false,
child: RecommendTopNav( child: ListenableBuilder(
selected: _tab, listenable: _pageController,
onVideo: _tab == RecommendInnerTab.feed, builder: (context, _) => RecommendTopNav(
onSelected: _selectTab, selected: _tab,
onSearch: _handleSearch, pagePosition: _pageController.pagePosition,
onGift: widget.onOpenWallet, onVideo: _tab == RecommendInnerTab.feed,
onSelected: _selectTab,
onSearch: _handleSearch,
onGift: widget.onOpenWallet,
),
), ),
), ),
), ),
@@ -290,10 +298,7 @@ class _RecommendPageState extends State<RecommendPage>
Widget _recommendFeed(BuildContext context) { Widget _recommendFeed(BuildContext context) {
final feed = widget.feedController; final feed = widget.feedController;
final dramas = visibleDramasForLocale( final dramas = widget.feedController.dramas;
widget.feedController.dramas,
Localizations.localeOf(context).languageCode,
);
if (dramas.isNotEmpty) { if (dramas.isNotEmpty) {
return PlayerPage( return PlayerPage(
key: const Key('recommend-feed-player'), key: const Key('recommend-feed-player'),
@@ -401,18 +406,16 @@ class _RecommendPageState extends State<RecommendPage>
_tab = tab; _tab = tab;
_scrubCancelRequest++; _scrubCancelRequest++;
}); });
if (_pageController.hasClients) { unawaited(
unawaited( _pageController.selectPage(
_pageController.animateToPage( tab.index,
tab.index, reduceMotion: MediaQuery.disableAnimationsOf(context),
duration: const Duration(milliseconds: 220), ),
curve: Curves.easeOut, );
),
);
}
} }
void _onPageChanged(int index) { void _onPageChanged(int index) {
if (!_pageController.acceptsSelection(index)) return;
final next = RecommendInnerTab.values[index]; final next = RecommendInnerTab.values[index];
if (next == _tab) return; if (next == _tab) return;
setState(() { setState(() {
@@ -484,110 +487,7 @@ class _RecommendPageState extends State<RecommendPage>
); );
} }
(String, String) _sectionCopy(
AppLocalizations l10n,
RecommendSectionKind section,
) {
return switch (section) {
RecommendSectionKind.hits => (
l10n.weeklyHitsTitle,
l10n.weeklyHitsSubtitle,
),
RecommendSectionKind.newReleases => (
l10n.weeklyNewTitle,
l10n.weeklyNewSubtitle,
),
RecommendSectionKind.costume => (
l10n.costumeAwardsTitle,
l10n.costumeAwardsSubtitle,
),
};
}
Future<void> _openDrama(Drama drama, {String? episodeId}) async { Future<void> _openDrama(Drama drama, {String? episodeId}) async {
await playDramaFromCatalog(context, drama, episodeId: episodeId); await playDramaFromCatalog(context, drama, episodeId: episodeId);
} }
} }
class _RecommendFeedSkeleton extends StatelessWidget {
const _RecommendFeedSkeleton();
@override
Widget build(BuildContext context) {
return const ColoredBox(
key: Key('recommend-feed-skeleton'),
color: AppColors.mediaBlack,
child: Stack(
children: [
Center(
child: SizedBox(
width: 132,
height: 176,
child: DecoratedBox(
decoration: BoxDecoration(
color: AppColors.skeletonMedia,
borderRadius: BorderRadius.all(Radius.circular(12)),
),
),
),
),
Positioned(
left: 14,
right: 72,
bottom: 42,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_SkeletonLine(width: 190, height: 18),
SizedBox(height: 10),
_SkeletonLine(width: 140, height: 12),
SizedBox(height: 8),
_SkeletonLine(width: 220, height: 10),
],
),
),
],
),
);
}
}
class _SkeletonLine extends StatelessWidget {
const _SkeletonLine({required this.width, required this.height});
final double width;
final double height;
@override
Widget build(BuildContext context) {
return Container(
width: width,
height: height,
decoration: BoxDecoration(
color: AppColors.skeletonMedia,
borderRadius: BorderRadius.circular(6),
),
);
}
}
class _KeepAliveTab extends StatefulWidget {
const _KeepAliveTab({super.key, required this.child});
final Widget child;
@override
State<_KeepAliveTab> createState() => _KeepAliveTabState();
}
class _KeepAliveTabState extends State<_KeepAliveTab>
with AutomaticKeepAliveClientMixin<_KeepAliveTab> {
@override
bool get wantKeepAlive => true;
@override
Widget build(BuildContext context) {
super.build(context);
return widget.child;
}
}
@@ -0,0 +1,116 @@
// Author: fruit.com.cn
// 职责:首页推荐流加载骨架、页面保活与静态文案,保持原有行为。
part of 'recommend_page.dart';
const double _posterWidth = 132;
const double _posterHeight = 176;
const double _posterRadius = 12;
const double _copyLeft = 14;
const double _copyRight = 72;
const double _copyBottom = 42;
const double _titleWidth = 190;
const double _titleHeight = 18;
const double _subtitleWidth = 140;
const double _subtitleHeight = 12;
const double _descriptionWidth = 220;
const double _descriptionHeight = 10;
const double _titleGap = 10;
const double _subtitleGap = 8;
const double _lineRadius = 6;
class _RecommendFeedSkeleton extends StatelessWidget {
const _RecommendFeedSkeleton();
@override
Widget build(BuildContext context) => const ColoredBox(
key: Key('recommend-feed-skeleton'),
color: AppColors.mediaBlack,
child: Stack(
children: [
Center(
child: SizedBox(
width: _posterWidth,
height: _posterHeight,
child: DecoratedBox(
decoration: BoxDecoration(
color: AppColors.skeletonMedia,
borderRadius: BorderRadius.all(Radius.circular(_posterRadius)),
),
),
),
),
Positioned(
left: _copyLeft,
right: _copyRight,
bottom: _copyBottom,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_SkeletonLine(width: _titleWidth, height: _titleHeight),
SizedBox(height: _titleGap),
_SkeletonLine(width: _subtitleWidth, height: _subtitleHeight),
SizedBox(height: _subtitleGap),
_SkeletonLine(
width: _descriptionWidth,
height: _descriptionHeight,
),
],
),
),
],
),
);
}
class _SkeletonLine extends StatelessWidget {
const _SkeletonLine({required this.width, required this.height});
final double width;
final double height;
@override
Widget build(BuildContext context) => Container(
width: width,
height: height,
decoration: BoxDecoration(
color: AppColors.skeletonMedia,
borderRadius: BorderRadius.circular(_lineRadius),
),
);
}
class _KeepAliveTab extends StatefulWidget {
const _KeepAliveTab({super.key, required this.child});
final Widget child;
@override
State<_KeepAliveTab> createState() => _KeepAliveTabState();
}
class _KeepAliveTabState extends State<_KeepAliveTab>
with AutomaticKeepAliveClientMixin<_KeepAliveTab> {
@override
bool get wantKeepAlive => true;
@override
Widget build(BuildContext context) {
super.build(context);
return widget.child;
}
}
(String, String) _sectionCopy(
AppLocalizations l10n,
RecommendSectionKind section,
) => switch (section) {
RecommendSectionKind.hits => (l10n.weeklyHitsTitle, l10n.weeklyHitsSubtitle),
RecommendSectionKind.newReleases => (
l10n.weeklyNewTitle,
l10n.weeklyNewSubtitle,
),
RecommendSectionKind.costume => (
l10n.costumeAwardsTitle,
l10n.costumeAwardsSubtitle,
),
};
@@ -10,7 +10,6 @@ import '../../../core/data/models/drama.dart';
import '../../../core/data/remote/catalog_api.dart'; import '../../../core/data/remote/catalog_api.dart';
import '../../../core/data/services/all_dramas_controller.dart'; import '../../../core/data/services/all_dramas_controller.dart';
import '../../../core/data/services/home_feed_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_colors.dart';
import '../../../core/theme/app_dimens.dart'; import '../../../core/theme/app_dimens.dart';
import '../../../core/widgets/app_pull_to_refresh.dart'; import '../../../core/widgets/app_pull_to_refresh.dart';
@@ -108,10 +107,7 @@ class _AllDramasFilterGridState extends State<AllDramasFilterGrid> {
} }
Widget _dramaList(AppLocalizations l10n, double itemExtent) { Widget _dramaList(AppLocalizations l10n, double itemExtent) {
final dramas = visibleDramasForLocale( final dramas = _dramas;
_dramas,
Localizations.localeOf(context).languageCode,
);
return NotificationListener<ScrollNotification>( return NotificationListener<ScrollNotification>(
onNotification: _onScroll, onNotification: _onScroll,
child: AppPullToRefresh( child: AppPullToRefresh(
@@ -122,7 +122,7 @@ class HomePosterCard extends StatelessWidget {
style: const TextStyle( style: const TextStyle(
color: AppColors.textPrimary, color: AppColors.textPrimary,
fontSize: 11, fontSize: 11,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w400,
height: 1.1, height: 1.1,
), ),
), ),
@@ -4,7 +4,6 @@ import 'package:flutter/material.dart';
import '../../../core/data/models/drama.dart'; import '../../../core/data/models/drama.dart';
import '../../../core/data/models/playlist_detail.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_colors.dart';
import '../../../core/theme/app_dimens.dart'; import '../../../core/theme/app_dimens.dart';
import '../../../core/widgets/app_pull_to_refresh.dart'; import '../../../core/widgets/app_pull_to_refresh.dart';
@@ -158,10 +157,7 @@ class _PlaylistDetailPageState extends State<PlaylistDetailPage> {
} }
Widget _body(double itemExtent) { Widget _body(double itemExtent) {
final visibleDramas = visibleDramasForLocale( final visibleDramas = _dramas;
_dramas,
Localizations.localeOf(context).languageCode,
);
return CustomScrollView( return CustomScrollView(
key: const Key('playlist-detail-scroll'), key: const Key('playlist-detail-scroll'),
physics: AppPullToRefresh.physics, physics: AppPullToRefresh.physics,
@@ -4,19 +4,21 @@ import 'package:flutter/material.dart';
import '../../../core/data/models/content_category.dart'; import '../../../core/data/models/content_category.dart';
import '../../../core/theme/app_colors.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 '../../../l10n/app_localizations.dart';
import 'search_gift_glyphs.dart'; import 'search_gift_glyphs.dart';
const double _navHeight = 56; const double _navHeight = 56;
const double _tabGap = 20; const double _tabGap = 20;
const double _tabFont = 17; const double _tabFont = 17;
const double _searchHitSize = 40; const double _searchHitSize = AppDimens.tapTarget;
const double _searchIconSize = 26; const double _searchIconSize = 26;
const double _giftIconSize = 26; const double _giftIconSize = 26;
const double _underlineWidth = 26; const double _underlineWidth = 26;
const double _underlineHeight = 3; const double _underlineHeight = 3;
/// 推荐页(礼花 tab)顶部频道导航:频道全部来自分类接口,右侧搜索/礼物。 /// 推荐页顶部频道导航:频道全部来自分类接口,右侧搜索/礼物。
class RecommendChannelNav extends StatefulWidget { class RecommendChannelNav extends StatefulWidget {
const RecommendChannelNav({ const RecommendChannelNav({
super.key, super.key,
@@ -49,8 +51,7 @@ class _RecommendChannelNavState extends State<RecommendChannelNav> {
final GlobalKey _scrollViewKey = GlobalKey(); final GlobalKey _scrollViewKey = GlobalKey();
final Map<String, GlobalKey> _tabKeys = {}; final Map<String, GlobalKey> _tabKeys = {};
GlobalKey _keyFor(String id) => GlobalKey _keyFor(String id) => _tabKeys.putIfAbsent(id, () => GlobalKey());
_tabKeys.putIfAbsent(id, () => GlobalKey());
@override @override
void didUpdateWidget(covariant RecommendChannelNav oldWidget) { void didUpdateWidget(covariant RecommendChannelNav oldWidget) {
@@ -82,9 +83,10 @@ class _RecommendChannelNavState extends State<RecommendChannelNav> {
final tabLeft = tabBox.localToGlobal(Offset.zero).dx; final tabLeft = tabBox.localToGlobal(Offset.zero).dx;
final relative = tabLeft - viewportLeft; final relative = tabLeft - viewportLeft;
final position = _scrollController.position; final position = _scrollController.position;
final target = final target = (position.pixels + relative - _revealLeftInset).clamp(
(position.pixels + relative - _revealLeftInset) 0.0,
.clamp(0.0, position.maxScrollExtent); position.maxScrollExtent,
);
if ((target - position.pixels).abs() < 0.5) return; if ((target - position.pixels).abs() < 0.5) return;
_scrollController.animateTo( _scrollController.animateTo(
target, target,
@@ -147,7 +149,10 @@ class _RecommendChannelNavState extends State<RecommendChannelNav> {
), ),
iconSize: _searchIconSize, iconSize: _searchIconSize,
color: AppColors.textPrimary, color: AppColors.textPrimary,
icon: SearchGlyph(size: _searchIconSize, color: AppColors.textPrimary), icon: SearchGlyph(
size: _searchIconSize,
color: AppColors.textPrimary,
),
), ),
), ),
if (widget.onGift != null) if (widget.onGift != null)
@@ -188,18 +193,16 @@ class _RecommendChannelNavState extends State<RecommendChannelNav> {
children: [ children: [
// 补偿文字下方的 4pt 间距 + 3pt 下划线,让文字视觉中心与右侧图标对齐。 // 补偿文字下方的 4pt 间距 + 3pt 下划线,让文字视觉中心与右侧图标对齐。
const SizedBox(height: 4 + _underlineHeight), const SizedBox(height: 4 + _underlineHeight),
Text( AnimatedNavLabel(
label, label: label,
maxLines: 1, selected: selected,
style: TextStyle( style: const TextStyle(
color: AppColors.textPrimary, color: AppColors.textPrimary,
fontSize: _tabFont, fontSize: _tabFont,
fontWeight: selected ? FontWeight.w700 : FontWeight.w400, fontWeight: FontWeight.w400,
letterSpacing: 0, letterSpacing: 0,
height: 1.0, height: 1.0,
shadows: const [ shadows: [Shadow(color: AppColors.mediaBlack, blurRadius: 6)],
Shadow(color: AppColors.mediaBlack, blurRadius: 6),
],
), ),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
@@ -8,7 +8,6 @@ import '../../../core/data/models/playlist_detail.dart';
import '../../../core/data/services/category_controller.dart'; import '../../../core/data/services/category_controller.dart';
import '../../../core/data/services/home_feed_controller.dart'; import '../../../core/data/services/home_feed_controller.dart';
import '../../../core/data/services/playlist_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_colors.dart';
import '../../../core/theme/app_dimens.dart'; import '../../../core/theme/app_dimens.dart';
import '../../../core/widgets/app_pull_to_refresh.dart'; import '../../../core/widgets/app_pull_to_refresh.dart';
@@ -44,8 +43,7 @@ class RecommendSectionsView extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
final languageCode = Localizations.localeOf(context).languageCode; final source = feedController.dramas;
final source = visibleDramasForLocale(feedController.dramas, languageCode);
final remote = playlists; final remote = playlists;
return AppPullToRefresh( return AppPullToRefresh(
key: const Key('recommend-sections-refresh'), key: const Key('recommend-sections-refresh'),
@@ -165,10 +163,7 @@ class RecommendSectionsView extends StatelessWidget {
subtitle: playlist.localizedDescription( subtitle: playlist.localizedDescription(
Localizations.localeOf(context).languageCode, Localizations.localeOf(context).languageCode,
), ),
dramas: visibleDramasForLocale( dramas: playlist.dramas.take(_sectionPreviewCount).toList(),
playlist.dramas,
Localizations.localeOf(context).languageCode,
).take(_sectionPreviewCount).toList(),
onMore: () => onOpenPlaylist?.call(playlist), onMore: () => onOpenPlaylist?.call(playlist),
), ),
); );
@@ -4,13 +4,14 @@ import 'package:flutter/material.dart';
import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart'; import '../../../core/theme/app_dimens.dart';
import '../../../core/widgets/animated_nav_label.dart';
import '../../../l10n/app_localizations.dart'; import '../../../l10n/app_localizations.dart';
import '../recommend_types.dart'; import '../recommend_types.dart';
import 'search_gift_glyphs.dart'; import 'search_gift_glyphs.dart';
const double _navHeight = 56; const double _navHeight = 56;
const double _tabGap = 20; const double _tabGap = 20;
const double _tabFont = 17; const double _tabFont = 18;
const double _maxNavigationTextScale = 1.3; const double _maxNavigationTextScale = 1.3;
const double _searchIconSize = 26; const double _searchIconSize = 26;
const double _giftIconSize = 26; const double _giftIconSize = 26;
@@ -25,6 +26,7 @@ class RecommendTopNav extends StatelessWidget {
required this.onSearch, required this.onSearch,
this.onVideo = false, this.onVideo = false,
this.onGift, this.onGift,
this.pagePosition,
}); });
static const double height = _navHeight; static const double height = _navHeight;
@@ -34,7 +36,10 @@ class RecommendTopNav extends StatelessWidget {
final VoidCallback onSearch; final VoidCallback onSearch;
final bool onVideo; final bool onVideo;
/// 右上角金色礼物盒(钱包入口);为空时不显示。 /// 首页横向页面的连续位置,用于文字和下划线跟手过渡。
final double? pagePosition;
/// 右上角礼包入口;为空时不显示。
final VoidCallback? onGift; final VoidCallback? onGift;
@override @override
@@ -65,14 +70,7 @@ class RecommendTopNav extends StatelessWidget {
children: [ children: [
for (var index = 0; index < items.length; index++) ...[ for (var index = 0; index < items.length; index++) ...[
if (index > 0) const SizedBox(width: _tabGap), if (index > 0) const SizedBox(width: _tabGap),
_tab( _tab(items[index], shadows, context),
key: Key('recommend-tab-${items[index].$3}'),
code: items[index].$3,
label: items[index].$2,
selected: selected == items[index].$1,
shadows: shadows,
onTap: () => onSelected(items[index].$1),
),
], ],
], ],
), ),
@@ -120,47 +118,79 @@ class RecommendTopNav extends StatelessWidget {
); );
} }
Widget _tab({ Widget _tab(
required Key key, (RecommendInnerTab, String, String) item,
required String code, List<Shadow>? shadows,
required String label, BuildContext context,
required bool selected, ) {
required List<Shadow>? shadows, final (tab, label, code) = item;
required VoidCallback onTap, final isSelected = selected == tab;
}) { final position = pagePosition;
final progress = position == null
? null
: (1 - (position - tab.index).abs()).clamp(0.0, 1.0);
return GestureDetector( return GestureDetector(
key: key, key: Key('recommend-tab-$code'),
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
onTap: onTap, onTap: () => onSelected(tab),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
// 补偿文字下方的 4pt 间距 + 3pt 下划线,让文字视觉中心与右侧图标对齐。 // 补偿文字下方的 4pt 间距 + 3pt 下划线,让文字视觉中心与右侧图标对齐。
const SizedBox(height: 4 + _underlineHeight), const SizedBox(height: 4 + _underlineHeight),
Text( AnimatedNavLabel(
label, label: label,
maxLines: 1, selected: isSelected,
selectionProgress: progress,
inactiveColor: AppColors.homeTabInactive,
style: TextStyle( style: TextStyle(
color: AppColors.textPrimary, color: AppColors.textPrimary,
fontSize: _tabFont, fontSize: _tabFont,
fontWeight: selected ? FontWeight.w700 : FontWeight.w400, fontWeight: FontWeight.w400,
letterSpacing: 0, letterSpacing: 0,
height: 1.0, height: 1.0,
shadows: shadows, shadows: shadows,
), ),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Container( _underline(
key: Key('recommend-tab-$code-underline'), code,
width: _underlineWidth, progress,
height: _underlineHeight, isSelected,
decoration: BoxDecoration( MediaQuery.disableAnimationsOf(context),
color: selected ? AppColors.brandRed : AppColors.transparent,
borderRadius: BorderRadius.circular(_underlineHeight / 2),
),
), ),
], ],
), ),
); );
} }
Widget _underline(
String code,
double? progress,
bool isSelected,
bool reduceMotion,
) {
if (progress != null) return _line(code, progress);
final target = isSelected ? 1.0 : 0.0;
return TweenAnimationBuilder<double>(
tween: Tween(begin: target, end: target),
duration: reduceMotion
? Duration.zero
: AppDimens.homePrimaryTabAnimationDuration,
curve: Curves.easeOut,
builder: (_, value, _) => _line(code, value),
);
}
Widget _line(String code, double progress) => Container(
key: Key('recommend-tab-$code-underline'),
width: _underlineWidth,
height: _underlineHeight,
decoration: BoxDecoration(
color: progress == 0
? AppColors.transparent
: AppColors.brandRed.withValues(alpha: progress),
borderRadius: BorderRadius.circular(_underlineHeight / 2),
),
);
} }
@@ -50,8 +50,12 @@ class _SearchPainter extends CustomPainter {
final center = Offset(circleDiameter / 2, circleDiameter / 2); final center = Offset(circleDiameter / 2, circleDiameter / 2);
canvas.drawCircle(center, circleDiameter / 2 - stroke / 2, paint); canvas.drawCircle(center, circleDiameter / 2 - stroke / 2, paint);
final start = center + Offset.fromDirection(direction, circleDiameter / 2 - stroke * 0.2); final start =
final end = center + Offset.fromDirection(direction, circleDiameter / 2 + size.width * 0.12); 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); canvas.drawLine(start, end, paint);
} }
@@ -60,7 +64,7 @@ class _SearchPainter extends CustomPainter {
oldDelegate.color != color; oldDelegate.color != color;
} }
/// emoji 礼盒(红盒金丝带,随系统 emoji 风格渲染)。 /// YMB 品牌礼盒静态图标,使用玫红礼盒与金色丝带。
class GiftGlyph extends StatelessWidget { class GiftGlyph extends StatelessWidget {
const GiftGlyph({super.key, required this.size}); const GiftGlyph({super.key, required this.size});
@@ -68,11 +72,94 @@ class GiftGlyph extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SizedBox.square( return CustomPaint(size: Size.square(size), painter: _GiftPainter());
dimension: size,
child: FittedBox(
child: Text('🎁', style: TextStyle(fontSize: size, height: 1)),
),
);
} }
} }
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', () { test('should_pick_address_by_environment_switch', () {
// 仓库里的开关默认指向开发环境。 // 跟随开关:开发分支为 true(开发地址),发版分支为 false(生产地址)。
expect(AppConfig.useDevConfig, isTrue); final expected = AppConfig.useDevConfig
expect(AppConfig.baseUrl, DevConfig.baseUrl); ? DevConfig.baseUrl
expect(AppConfig.apiBaseUrl, DevConfig.baseUrl); : ProdConfig.baseUrl;
expect(AppConfig.baseUrl, expected);
expect(AppConfig.apiBaseUrl, expected);
expect(Uri.parse(AppConfig.baseUrl).port, 8081); expect(Uri.parse(AppConfig.baseUrl).port, 8081);
}); });
@@ -87,7 +89,10 @@ void main() {
AppConfig.applyRuntimeBaseUrl('http://runtime.test:8081'); AppConfig.applyRuntimeBaseUrl('http://runtime.test:8081');
expect(AppConfig.apiBaseUrl, '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', () { test('should_append_host_to_relative_media_paths', () {
+110
View File
@@ -0,0 +1,110 @@
// 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/core/widgets/animated_nav_label.dart';
const _halfDuration = Duration(milliseconds: 90);
const _duration = Duration(milliseconds: 180);
const _style = TextStyle(fontSize: 20, color: AppColors.navSelected);
const _firstKey = Key('first');
const _secondKey = Key('second');
Widget _labels(bool firstSelected, {bool reduceMotion = false}) {
return MaterialApp(
home: MediaQuery(
data: MediaQueryData(
devicePixelRatio: 3,
disableAnimations: reduceMotion,
),
child: Center(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedNavLabel(
key: _firstKey,
label: '精选 Featured',
selected: firstSelected,
style: _style,
),
AnimatedNavLabel(
key: _secondKey,
label: '推荐 Rekomendasi',
selected: !firstSelected,
style: _style,
),
],
),
),
),
);
}
double _scale(WidgetTester tester, Key key) {
final transform = tester.widget<Transform>(
find.descendant(of: find.byKey(key), matching: find.byType(Transform)),
);
return transform.transform.entry(0, 0);
}
Color _color(WidgetTester tester, Key key) {
return tester
.widget<Text>(
find.descendant(of: find.byKey(key), matching: find.byType(Text)),
)
.style!
.color!;
}
void main() {
testWidgets('should_cross_animate_without_relayout_when_selection_changes', (
tester,
) async {
await tester.pumpWidget(_labels(true));
final firstRect = tester.getRect(find.byKey(_firstKey));
final secondRect = tester.getRect(find.byKey(_secondKey));
await tester.pumpWidget(_labels(false));
await tester.pump(_halfDuration);
expect(_scale(tester, _firstKey), inExclusiveRange(0.9, 1.0));
expect(_scale(tester, _secondKey), inExclusiveRange(0.9, 1.0));
expect(_color(tester, _firstKey), isNot(AppColors.navSelected));
expect(_color(tester, _firstKey), isNot(AppColors.textTabInactive));
expect(_color(tester, _secondKey), isNot(AppColors.navSelected));
expect(_color(tester, _secondKey), isNot(AppColors.textTabInactive));
expect(tester.getRect(find.byKey(_firstKey)), firstRect);
expect(tester.getRect(find.byKey(_secondKey)), secondRect);
await tester.pump(_duration);
expect(_scale(tester, _firstKey), closeTo(0.9, 0.0001));
expect(_scale(tester, _secondKey), 1);
expect(_color(tester, _secondKey), AppColors.navSelected);
expect(tester.getRect(find.byKey(_secondKey)), secondRect);
});
testWidgets('should_continue_from_current_value_when_selection_reverses', (
tester,
) async {
await tester.pumpWidget(_labels(true));
await tester.pumpWidget(_labels(false));
await tester.pump(_halfDuration);
final scale = _scale(tester, _firstKey);
final color = _color(tester, _firstKey);
await tester.pumpWidget(_labels(true));
expect(_scale(tester, _firstKey), scale);
expect(_color(tester, _firstKey), color);
await tester.pump(_duration);
expect(_scale(tester, _firstKey), 1);
});
testWidgets('should_skip_transition_when_reduce_motion_is_enabled', (
tester,
) async {
await tester.pumpWidget(_labels(true, reduceMotion: true));
await tester.pumpWidget(_labels(false, reduceMotion: true));
await tester.pump();
expect(_scale(tester, _firstKey), closeTo(0.9, 0.0001));
expect(_color(tester, _secondKey), AppColors.navSelected);
expect(tester.binding.hasScheduledFrame, isFalse);
});
}
@@ -0,0 +1,114 @@
// Author: fruit.com.cn
// 职责:验证图片占位扫光生命周期及默认兜底接入。
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/widgets/image_placeholder_shimmer.dart';
import 'package:fruitshorts/core/widgets/remote_image.dart';
const _frame = Duration(milliseconds: 350);
const double _width = 120;
const double _height = 160;
const _size = Size(_width, _height);
Widget _placeholder({bool reduceMotion = false, bool active = true}) {
return MaterialApp(
home: MediaQuery(
data: MediaQueryData(disableAnimations: reduceMotion),
child: TickerMode(
enabled: active,
child: const Center(
child: SizedBox(
width: _width,
height: _height,
child: ImagePlaceholderShimmer(
child: ColoredBox(color: Colors.black),
),
),
),
),
),
);
}
void main() {
testWidgets('should_paint_clipped_sweep_when_placeholder_is_loading', (
tester,
) async {
await tester.pumpWidget(_placeholder());
final shimmer = find.byType(ImagePlaceholderShimmer);
final paintFinder = find.descendant(
of: shimmer,
matching: find.byType(CustomPaint),
);
final paint = tester.widget<CustomPaint>(paintFinder);
var repaints = 0;
paint.foregroundPainter!.addListener(() => repaints++);
await tester.pump(_frame);
expect(repaints, greaterThan(0));
expect(tester.getSize(shimmer), _size);
expect(
find.descendant(of: shimmer, matching: find.byType(ClipRect)),
findsOneWidget,
);
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump(_frame);
expect(tester.binding.hasScheduledFrame, isFalse);
});
testWidgets('should_pause_sweep_when_motion_is_disabled_or_page_is_hidden', (
tester,
) async {
await tester.pumpWidget(_placeholder());
await tester.pump(_frame);
await tester.pumpWidget(_placeholder(reduceMotion: true));
await tester.pump(_frame);
expect(tester.binding.hasScheduledFrame, isFalse);
await tester.pumpWidget(_placeholder(active: false));
await tester.pump(_frame);
expect(tester.binding.hasScheduledFrame, isFalse);
await tester.pumpWidget(_placeholder());
await tester.pump(_frame);
expect(tester.binding.hasScheduledFrame, isTrue);
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets(
'should_sweep_only_default_loading_fallback_when_image_is_pending',
(tester) async {
await tester.pumpWidget(
const MaterialApp(
home: SizedBox(
width: _width,
height: _height,
child: RemoteImage('/files/pending-cover.jpg'),
),
),
);
final image = tester.widget<Image>(find.byType(Image));
final context = tester.element(find.byType(RemoteImage));
const loaded = SizedBox(key: Key('loaded'));
expect(
image.frameBuilder!(context, loaded, null, false),
isA<ImagePlaceholderShimmer>(),
);
expect(image.frameBuilder!(context, loaded, 0, false), same(loaded));
expect(
image.errorBuilder!(context, Exception('failed'), null),
isNot(isA<ImagePlaceholderShimmer>()),
);
await tester.pumpWidget(const MaterialApp(home: RemoteImage('')));
expect(find.byType(ImagePlaceholderShimmer), findsNothing);
await tester.pumpWidget(
const MaterialApp(
home: RemoteImage(
'/files/custom-cover.jpg',
fallbackWidget: SizedBox(key: Key('custom')),
),
),
);
expect(find.byKey(const Key('custom')), findsOneWidget);
expect(find.byType(ImagePlaceholderShimmer), findsNothing);
await tester.pumpWidget(const SizedBox.shrink());
},
);
}
@@ -0,0 +1,143 @@
// Author: fruit.com.cn
// 职责:验证追剧页移除顶部横幅/重复收藏标题后,卡片直达及历史续播能力。
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/app.dart';
import 'package:fruitshorts/core/data/models/watch_progress.dart';
import 'package:fruitshorts/core/data/repositories/favorite_repository.dart';
import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart';
import 'package:fruitshorts/l10n/app_localizations.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';
import '../support/fake_catalog.dart';
import '../support/fake_category.dart';
import '../support/fake_interaction_repositories.dart';
import '../support/widget_player.dart';
const _viewport = Size(390, 844);
const _tolerance = 0.1;
const _dramaId = 'the-signal-demo';
const _banner = Key('following-continue-banner');
const _page = Key('following-page');
const _favoritesGrid = Key('following-favorites-grid');
const _historyRecord = WatchProgress(
dramaId: _dramaId,
episodeId: 'the-signal-demo-3',
dramaTitle: 'Recent story',
episodeNumber: 3,
totalEpisodes: 8,
position: Duration(seconds: 25),
duration: Duration(seconds: 100),
);
Future<void> _pumpFollowing(
WidgetTester tester, {
bool hasFavorites = true,
}) async {
tester.view.physicalSize = _viewport;
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final catalog = FakeCatalogRepository();
final cards = hasFavorites
? catalog.dramas.take(1).toList()
: catalog.dramas.take(0).toList();
await tester.pumpWidget(
LumaDramaApp(
playerFactory: createWidgetPlayer,
catalogRepository: catalog,
categoryRepository: FakeCategoryRepository(catalog: catalog),
watchHistoryRepository: InMemoryWatchHistoryRepository(
seed: [_historyRecord],
),
favoriteRepository: InMemoryFavoriteRepository(
cards: cards,
saved: cards.map((drama) => drama.id).toSet(),
),
playbackRepository: FakePlaybackRepository(),
commentRepository: FakeCommentRepository(),
interactionRepository: FakeInteractionRepository(),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('tab-follow')));
await tester.pumpAndSettle();
}
void main() {
setUp(() {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.empty();
});
testWidgets('should_hide_continue_banner_when_recent_history_exists', (
tester,
) async {
await _pumpFollowing(tester);
expect(find.byKey(_banner), findsNothing);
expect(find.byKey(const Key('following-card-$_dramaId')), findsOneWidget);
expect(find.byKey(const Key('following-add-card')), findsOneWidget);
expect(tester.takeException(), isNull);
});
for (final hasFavorites in [true, false]) {
testWidgets(
'should_omit_section_heading_and_count_when_favorites_are_$hasFavorites',
(tester) async {
await _pumpFollowing(tester, hasFavorites: hasFavorites);
final l10n = AppLocalizations.of(tester.element(find.byKey(_page)))!;
final grid = find.byKey(_favoritesGrid);
expect(
find.descendant(of: grid, matching: find.text(l10n.favoriteDramas)),
findsNothing,
);
expect(
find.descendant(
of: grid,
matching: find.text(l10n.totalDramasCount(hasFavorites ? 1 : 0)),
),
findsNothing,
);
if (hasFavorites) {
final header = tester.getRect(
find.byKey(const Key('following-header')),
);
final card = tester.getRect(
find.byKey(const Key('following-card-$_dramaId')),
);
expect(card.top, closeTo(header.bottom, _tolerance));
} else {
expect(
find.byKey(const Key('following-favorites-empty')),
findsOneWidget,
);
}
expect(tester.takeException(), isNull);
},
);
}
testWidgets('should_keep_history_continue_entry_when_history_tab_opens', (
tester,
) async {
await _pumpFollowing(tester);
await tester.tap(find.byKey(const Key('following-tab-history')));
await tester.pumpAndSettle();
expect(
find.byKey(const Key('following-history-card-$_dramaId')),
findsOneWidget,
);
expect(
find.byKey(const Key('following-history-continue-$_dramaId')),
findsOneWidget,
);
expect(
find.byKey(const Key('following-progress-$_dramaId')),
findsOneWidget,
);
expect(find.byKey(_banner), findsNothing);
expect(tester.takeException(), isNull);
});
}
+3 -31
View File
@@ -1,7 +1,6 @@
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/models/drama.dart'; import 'package:fruitshorts/core/data/models/drama.dart';
import 'package:fruitshorts/core/formatting/localized_content.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_en.dart';
import 'package:fruitshorts/l10n/app_localizations_id.dart'; import 'package:fruitshorts/l10n/app_localizations_id.dart';
import 'package:fruitshorts/l10n/app_localizations_zh.dart'; import 'package:fruitshorts/l10n/app_localizations_zh.dart';
@@ -77,7 +76,7 @@ void main() {
expect(localizedDramaSynopsis(zh, custom), 'Custom synopsis'); 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( const drama = Drama(
id: 'missing-id-copy', id: 'missing-id-copy',
title: '古城', title: '古城',
@@ -87,35 +86,8 @@ void main() {
posterUrl: '/files/poster.png', posterUrl: '/files/poster.png',
); );
expect(drama.localizedTitle('id'), isEmpty); expect(drama.localizedTitle('id'), '古城');
expect(drama.localizedSynopsis('id'), isEmpty); expect(drama.localizedSynopsis('id'), '中文简介');
});
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,
]);
}); });
test('should_ignore_raw_catalog_text_when_drama_is_seeded', () { test('should_ignore_raw_catalog_text_when_drama_is_seeded', () {
+467
View File
@@ -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.homeFeedVideoCopy,
);
_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.homeFeedVideoCopy);
});
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.homeFeedVideoCopy);
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.homeFeedVideoCopy);
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);
});
}
+254
View File
@@ -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));
});
});
}
+66
View File
@@ -0,0 +1,66 @@
// 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/l10n/app_localizations.dart';
import 'package:fruitshorts/pages/recommend/recommend_types.dart';
import 'package:fruitshorts/pages/recommend/widgets/recommend_top_nav.dart';
const _labels = [
('zh', '精选', '推荐', '剧单', '全部短剧'),
('en', 'Featured', 'Recommend', 'Playlists', 'All Dramas'),
('id', 'Unggulan', 'Rekomendasi', 'Daftar Putar', 'Semua'),
];
void main() {
for (final (language, featured, recommend, playlists, all) in _labels) {
testWidgets(
'should_distinguish_featured_from_recommend_when_locale_is_$language',
(tester) async {
await tester.pumpWidget(_host(language));
await tester.pumpAndSettle();
final top = find.byKey(const Key('recommend-top-nav'));
final bottom = find.byKey(const Key('tab-recommend'));
expect(
find.descendant(of: top, matching: find.text(featured)),
findsOneWidget,
);
expect(
find.descendant(of: top, matching: find.text(recommend)),
findsNothing,
);
expect(
find.descendant(of: bottom, matching: find.text(recommend)),
findsOneWidget,
);
expect(
find.descendant(of: top, matching: find.text(playlists)),
findsOneWidget,
);
expect(
find.descendant(of: top, matching: find.text(all)),
findsOneWidget,
);
expect(tester.takeException(), isNull);
},
);
}
}
Widget _host(String language) => MaterialApp(
locale: Locale(language),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: RecommendTopNav(
selected: RecommendInnerTab.feed,
onSelected: (_) {},
onSearch: () {},
),
bottomNavigationBar: AppRootBottomNav(
selected: AppRootTab.home,
onSelected: (_) {},
),
),
);
+44 -2
View File
@@ -13,6 +13,8 @@ import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart';
import 'package:fruitshorts/core/theme/app_colors.dart'; import 'package:fruitshorts/core/theme/app_colors.dart';
import 'package:fruitshorts/pages/player/player_view.dart'; import 'package:fruitshorts/pages/player/player_view.dart';
import 'package:fruitshorts/pages/recommend/recommend_types.dart';
import 'package:fruitshorts/pages/recommend/widgets/recommend_top_nav.dart';
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.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'; import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
@@ -50,7 +52,7 @@ void main() {
.toList(); .toList();
expect(centers, orderedEquals(centers.toList()..sort())); expect(centers, orderedEquals(centers.toList()..sort()));
expect(find.byKey(const Key('recommend-section-hits')), findsNothing); expect(find.byKey(const Key('recommend-section-hits')), findsNothing);
expect(find.text('Featured'), findsNothing); expect(find.text('Featured'), findsOneWidget);
expect(find.text('Categories'), findsNothing); expect(find.text('Categories'), findsNothing);
expect(find.text('Charts'), findsNothing); expect(find.text('Charts'), findsNothing);
expect(find.byKey(const Key('mine-mini-player')), findsNothing); expect(find.byKey(const Key('mine-mini-player')), findsNothing);
@@ -173,7 +175,7 @@ void main() {
matching: find.text('Playlists'), matching: find.text('Playlists'),
), ),
); );
expect(selected.style?.fontWeight, FontWeight.w700); expect(selected.style?.fontWeight, FontWeight.w400);
expect( expect(
tester.getRect(find.byKey(const Key('recommend-top-nav'))), tester.getRect(find.byKey(const Key('recommend-top-nav'))),
topNavBefore, topNavBefore,
@@ -191,6 +193,46 @@ void main() {
expect(find.byKey(const Key('recommend-tab-recommend')), findsOneWidget); expect(find.byKey(const Key('recommend-tab-recommend')), findsOneWidget);
}); });
testWidgets('should_follow_partial_swipe_before_selected_page_changes', (
tester,
) async {
await _pumpApp(tester, _PagedCatalogRepository());
final pages = find.byKey(const Key('recommend-tab-page-view'));
final gesture = await tester.startGesture(tester.getCenter(pages));
await gesture.moveBy(const Offset(-30, 0));
await tester.pump();
await gesture.moveBy(const Offset(-80, 0));
await tester.pump();
final nav = tester.widget<RecommendTopNav>(find.byType(RecommendTopNav));
expect(nav.pagePosition, inExclusiveRange(0.0, 0.5));
final underline = tester.widget<Container>(
find.byKey(const Key('recommend-tab-playlist-underline')),
);
final color = (underline.decoration! as BoxDecoration).color!;
expect(color.a, inExclusiveRange(0.0, 0.5));
await gesture.up();
await tester.pumpAndSettle();
_drainLayoutExceptions(tester);
});
testWidgets(
'should_keep_all_dramas_selected_during_direct_cross_page_click',
(tester) async {
await _pumpApp(tester, _PagedCatalogRepository());
await tester.ensureVisible(find.byKey(const Key('recommend-tab-all')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('recommend-tab-all')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 70));
final nav = tester.widget<RecommendTopNav>(find.byType(RecommendTopNav));
expect(nav.pagePosition, inExclusiveRange(0.5, 1.5));
expect(nav.selected, RecommendInnerTab.allDramas);
await tester.pumpAndSettle();
_drainLayoutExceptions(tester);
expect(find.byKey(const Key('all-dramas-grid')), findsOneWidget);
},
);
testWidgets('should_load_next_page_without_expanding_page_size', ( testWidgets('should_load_next_page_without_expanding_page_size', (
tester, tester,
) async { ) async {
+18 -18
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_colors.dart';
import 'package:fruitshorts/core/theme/app_dimens.dart'; import 'package:fruitshorts/core/theme/app_dimens.dart';
import 'package:fruitshorts/core/widgets/app_bottom_nav.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:fruitshorts/pages/following/following_page.dart';
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.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'; import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
@@ -67,10 +68,7 @@ void main() {
tester.getCenter(searchEntry).dy, tester.getCenter(searchEntry).dy,
closeTo(tester.getCenter(walletEntry).dy, 0.1), closeTo(tester.getCenter(walletEntry).dy, 0.1),
); );
expect( expect(tester.getTopRight(walletEntry).dx, closeTo(390 - 8, 0.1));
tester.getTopRight(walletEntry).dx,
closeTo(390 - 8, 0.1),
);
expect( expect(
tester.getTopRight(searchEntry).dx, tester.getTopRight(searchEntry).dx,
closeTo(tester.getTopLeft(walletEntry).dx, 0.1), closeTo(tester.getTopLeft(walletEntry).dx, 0.1),
@@ -141,18 +139,20 @@ void main() {
.duration, .duration,
lessThanOrEqualTo(const Duration(milliseconds: 200)), lessThanOrEqualTo(const Duration(milliseconds: 200)),
); );
final selectedHomeIcon = tester.widget<Icon>( final selectedHomeIcon = tester.widget<RootNavGlyph>(
find.descendant( find.descendant(
of: find.byKey(const Key('tab-home')), 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( expect(
tester.getSize( tester.getSize(
find.descendant( find.descendant(
of: find.byKey(const Key('tab-home')), of: find.byKey(const Key('tab-home')),
matching: find.byIcon(Icons.home_rounded), matching: find.byType(RootNavGlyph),
), ),
), ),
const Size.square(AppDimens.navIcon), const Size.square(AppDimens.navIcon),
@@ -160,21 +160,21 @@ void main() {
expect( expect(
find.descendant( find.descendant(
of: find.byKey(const Key('tab-recommend')), of: find.byKey(const Key('tab-recommend')),
matching: find.byIcon(Icons.celebration_outlined), matching: find.byType(RootNavGlyph),
), ),
findsOneWidget, findsOneWidget,
); );
expect( expect(
find.descendant( find.descendant(
of: find.byKey(const Key('tab-follow')), of: find.byKey(const Key('tab-follow')),
matching: find.byIcon(Icons.bookmark_border_rounded), matching: find.byType(RootNavGlyph),
), ),
findsOneWidget, findsOneWidget,
); );
expect( expect(
find.descendant( find.descendant(
of: find.byKey(const Key('tab-mine')), of: find.byKey(const Key('tab-mine')),
matching: find.byIcon(Icons.person_outline_rounded), matching: find.byType(RootNavGlyph),
), ),
findsOneWidget, findsOneWidget,
); );
@@ -191,7 +191,7 @@ void main() {
expect( expect(
find.descendant( find.descendant(
of: find.byKey(const Key('tab-recommend')), of: find.byKey(const Key('tab-recommend')),
matching: find.byIcon(Icons.celebration_rounded), matching: find.byType(RootNavGlyph),
), ),
findsOneWidget, findsOneWidget,
); );
@@ -222,20 +222,20 @@ void main() {
expect( expect(
find.descendant( find.descendant(
of: find.byKey(const Key('tab-home')), of: find.byKey(const Key('tab-home')),
matching: find.byIcon(Icons.home_outlined), matching: find.byType(RootNavGlyph),
), ),
findsOneWidget, findsOneWidget,
); );
expect( expect(
tester tester
.widget<Icon>( .widget<RootNavGlyph>(
find.descendant( find.descendant(
of: find.byKey(const Key('tab-home')), of: find.byKey(const Key('tab-home')),
matching: find.byIcon(Icons.home_outlined), matching: find.byType(RootNavGlyph),
), ),
) )
.color, .selected,
AppColors.navUnselected, isFalse,
); );
}); });
@@ -273,7 +273,7 @@ void main() {
expect( expect(
find.descendant( find.descendant(
of: find.byKey(const Key('tab-follow')), of: find.byKey(const Key('tab-follow')),
matching: find.byIcon(Icons.bookmark_rounded), matching: find.byType(RootNavGlyph),
), ),
findsOneWidget, findsOneWidget,
); );
+25 -14
View File
@@ -198,6 +198,11 @@ void main() {
posterUrl: '', posterUrl: '',
genre: 'Romance', genre: 'Romance',
category: 'Urban', category: 'Urban',
types: [
{'en': 'Romance'},
{'en': 'Urban'},
{'en': 'Revenge'},
],
totalEpisodes: 8, totalEpisodes: 8,
freeEpisodes: 8, freeEpisodes: 8,
viewCount: 419000, viewCount: 419000,
@@ -255,7 +260,7 @@ void main() {
); );
expect(seriesTitle.data, 'Feed Drama'); expect(seriesTitle.data, 'Feed Drama');
expect(find.text('Episode 2'), findsNothing); expect(find.text('Episode 2'), findsNothing);
expect(seriesTitle.style?.fontSize, 17); expect(seriesTitle.style?.fontSize, 18);
expect(seriesTitle.style?.fontWeight, FontWeight.w400); expect(seriesTitle.style?.fontWeight, FontWeight.w400);
expect(seriesTitle.style?.color, AppColors.textPrimary); expect(seriesTitle.style?.color, AppColors.textPrimary);
final seriesChevron = tester.widget<Icon>( final seriesChevron = tester.widget<Icon>(
@@ -284,11 +289,13 @@ void main() {
expect(find.byKey(const Key('home-feed-full-series')), findsOneWidget); expect(find.byKey(const Key('home-feed-full-series')), findsOneWidget);
expect(find.byKey(const Key('home-feed-tags')), findsOneWidget); expect(find.byKey(const Key('home-feed-tags')), findsOneWidget);
expect(find.byKey(const Key('home-feed-tag-Romance')), findsOneWidget); expect(find.byKey(const Key('home-feed-tag-Romance')), findsOneWidget);
expect(find.byKey(const Key('home-feed-hot-badge')), findsNothing);
expect(find.byKey(const Key('home-feed-tag-Urban')), findsOneWidget); expect(find.byKey(const Key('home-feed-tag-Urban')), findsOneWidget);
expect(find.byKey(const Key('home-feed-tag-Revenge')), findsNothing);
final collapsedSynopsis = tester.widget<Text>( final collapsedSynopsis = tester.widget<Text>(
find.byKey(const Key('home-feed-synopsis')), find.byKey(const Key('home-feed-synopsis')),
); );
expect(collapsedSynopsis.maxLines, 1); expect(collapsedSynopsis.maxLines, 2);
expect(collapsedSynopsis.overflow, TextOverflow.ellipsis); expect(collapsedSynopsis.overflow, TextOverflow.ellipsis);
expect(find.byKey(const Key('home-feed-synopsis-more')), findsOneWidget); expect(find.byKey(const Key('home-feed-synopsis-more')), findsOneWidget);
await tester.tap(find.byKey(const Key('home-feed-synopsis-more'))); await tester.tap(find.byKey(const Key('home-feed-synopsis-more')));
@@ -302,8 +309,9 @@ void main() {
.widget<Text>(find.byKey(const Key('home-feed-synopsis-expanded'))) .widget<Text>(find.byKey(const Key('home-feed-synopsis-expanded')))
.textSpan! .textSpan!
.toPlainText(), .toPlainText(),
allOf(contains('EP2'), contains('dangerous city')), contains('dangerous city'),
); );
expect(find.text('Episode 2 / 8'), findsOneWidget);
expect( expect(
tester.widget<Text>(find.byKey(const Key('rail-like-count'))).data, tester.widget<Text>(find.byKey(const Key('rail-like-count'))).data,
'1006', '1006',
@@ -358,14 +366,14 @@ void main() {
final likeRect = tester.getRect(find.byKey(const Key('rail-like'))); final likeRect = tester.getRect(find.byKey(const Key('rail-like')));
final commentsRect = tester.getRect(find.byKey(const Key('rail-comments'))); final commentsRect = tester.getRect(find.byKey(const Key('rail-comments')));
final shareRect = tester.getRect(find.byKey(const Key('rail-share'))); final shareRect = tester.getRect(find.byKey(const Key('rail-share')));
// 新版互动栏顺序:收藏 → 评论 → 喜欢 → 分享,间距 12。 // 互动栏顺序:收藏 → 评论 → 喜欢 → 分享,紧凑间距 6。
expect(commentsRect.top - saveRect.bottom, closeTo(12, 0.1)); expect(commentsRect.top - saveRect.bottom, closeTo(6, 0.1));
expect(likeRect.top - commentsRect.bottom, closeTo(12, 0.1)); expect(likeRect.top - commentsRect.bottom, closeTo(6, 0.1));
expect(shareRect.top - likeRect.bottom, closeTo(12, 0.1)); expect(shareRect.top - likeRect.bottom, closeTo(6, 0.1));
expect( expect(
tester.getRect(find.byKey(const Key('home-feed-action-rail'))).right, tester.getRect(find.byKey(const Key('home-feed-action-rail'))).right,
closeTo( 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, 0.1,
), ),
); );
@@ -582,9 +590,13 @@ void main() {
final overlayRect = tester.getRect( final overlayRect = tester.getRect(
find.byKey(const Key('home-feed-gesture')), find.byKey(const Key('home-feed-gesture')),
); );
// 观看条与底部进度条共用同一组左右边距(各 20pt),宽度一致。 // 观看条与左侧信息等宽,进度条仍横跨整个页面,避开右侧互动栏。
expect(bar.left, closeTo(overlayRect.left + 20, 0.1)); expect(bar.left, closeTo(overlayRect.left + 20, 0.1));
expect(bar.width, closeTo(overlayRect.width - 40, 0.1)); final infoRect = tester.getRect(
find.byKey(const Key('home-feed-info-panel')),
);
expect(bar.width, closeTo(infoRect.width, 0.1));
expect(bar.right, closeTo(infoRect.right, 0.1));
expect( expect(
bar.left, bar.left,
closeTo( closeTo(
@@ -594,9 +606,8 @@ void main() {
); );
expect( expect(
bar.width, bar.width,
closeTo( lessThan(
tester.getRect(find.byKey(const Key('home-feed-progress'))).width, tester.getRect(find.byKey(const Key('home-feed-progress'))).width,
0.1,
), ),
); );
// 进度线贴着浮层底部(线心距底 4pt),且位于观看条下方。 // 进度线贴着浮层底部(线心距底 4pt),且位于观看条下方。
@@ -609,10 +620,10 @@ void main() {
tester tester
.widget<Material>(find.byKey(const Key('home-feed-full-series-bg'))) .widget<Material>(find.byKey(const Key('home-feed-full-series-bg')))
.color, .color,
AppColors.textPrimary.withValues(alpha: 0.15), Colors.transparent,
); );
expect( expect(
tester.getRect(find.textContaining('Watch full series')).center.dy, tester.getRect(find.text('Watch all')).center.dy,
closeTo(bar.center.dy, 2), closeTo(bar.center.dy, 2),
); );
expect( expect(
+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,
]);
},
);
}
+149
View File
@@ -0,0 +1,149 @@
// Author: fruit.com.cn
// 职责:验证三种顶部导航接入稳定文字过渡并保留点击与选中语义。
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/models/content_category.dart';
import 'package:fruitshorts/core/widgets/animated_nav_label.dart';
import 'package:fruitshorts/l10n/app_localizations.dart';
import 'package:fruitshorts/pages/home/widgets/home_grid_header.dart';
import 'package:fruitshorts/pages/recommend/recommend_types.dart';
import 'package:fruitshorts/pages/recommend/widgets/recommend_channel_nav.dart';
import 'package:fruitshorts/pages/recommend/widgets/recommend_top_nav.dart';
const _midpoint = Duration(milliseconds: 90);
const _duration = Duration(milliseconds: 180);
const _categories = [
ContentCategory(id: 'featured', name: '精选'),
ContentCategory(id: 'new', name: '新剧'),
];
const _shortcuts = [
HomeGridCategoryShortcut(
label: '精选',
categoryName: 'featured',
categoryId: 'featured',
tab: HomePrimaryTab.featured,
),
HomeGridCategoryShortcut(
label: '新剧',
categoryName: 'new',
categoryId: 'new',
tab: HomePrimaryTab.newReleases,
),
];
Widget _host(Widget child) => MaterialApp(
locale: const Locale('zh'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(body: child),
);
double _scale(WidgetTester tester, Finder label) {
return tester
.widget<Transform>(
find.descendant(of: label, matching: find.byType(Transform)),
)
.transform
.entry(0, 0);
}
Future<void> _verifyTransition(
WidgetTester tester,
Widget Function(bool) build,
) async {
await tester.pumpWidget(_host(build(true)));
await tester.pumpAndSettle();
final labels = find.byType(AnimatedNavLabel);
final rects = [tester.getRect(labels.at(0)), tester.getRect(labels.at(1))];
await tester.pumpWidget(_host(build(false)));
await tester.pump(_midpoint);
expect(_scale(tester, labels.at(0)), inExclusiveRange(0.9, 1.0));
expect(_scale(tester, labels.at(1)), inExclusiveRange(0.9, 1.0));
expect(tester.getRect(labels.at(0)), rects[0]);
expect(tester.getRect(labels.at(1)), rects[1]);
await tester.pump(_duration);
expect(tester.getRect(labels.at(1)), rects[1]);
final semantics = tester.widget<Semantics>(
find.descendant(of: labels.at(1), matching: find.byType(Semantics)).first,
);
expect(semantics.properties.selected, isTrue);
}
void main() {
testWidgets('should_keep_channel_labels_regular_when_selection_changes', (
tester,
) async {
const categories = [
ContentCategory(id: 'featured', name: '精选'),
ContentCategory(id: 'weekly', name: '本周必看'),
];
for (final selected in categories) {
await tester.pumpWidget(
_host(
RecommendChannelNav(
categories: categories,
selectedId: selected.id,
onSelected: (_) {},
onSearch: () {},
),
),
);
await tester.pumpAndSettle();
for (final category in categories) {
final text = tester.widget<Text>(find.text(category.name));
expect(text.style?.fontWeight, FontWeight.w400);
expect(text.style?.fontSize, 17);
}
expect(tester.takeException(), isNull);
}
});
testWidgets('should_keep_home_labels_stable_when_category_changes', (
tester,
) async {
int? tapped;
HomeGridHeader header(bool first) => HomeGridHeader(
callbacks: HomeGridCallbacks(
onSearch: () {},
onOpenVip: () {},
onOpenWallet: () {},
onOpenFilter: () {},
),
categories: _shortcuts,
selectedIndex: first ? 0 : 1,
onSectionSelected: (index) => tapped = index,
);
await _verifyTransition(tester, header);
await tester.tap(find.byKey(const Key('home-primary-label-new')));
expect(tapped, 1);
});
testWidgets('should_keep_inner_nav_labels_stable_when_tab_changes', (
tester,
) async {
RecommendInnerTab? tapped;
RecommendTopNav nav(bool first) => RecommendTopNav(
selected: first ? RecommendInnerTab.feed : RecommendInnerTab.playlists,
onSelected: (tab) => tapped = tab,
onSearch: () {},
);
await _verifyTransition(tester, nav);
await tester.tap(find.byKey(const Key('recommend-tab-playlist')));
expect(tapped, RecommendInnerTab.playlists);
});
testWidgets('should_keep_channel_labels_stable_when_selection_changes', (
tester,
) async {
String? tapped;
RecommendChannelNav nav(bool first) => RecommendChannelNav(
categories: _categories,
selectedId: first ? 'featured' : 'new',
onSelected: (id) => tapped = id,
onSearch: () {},
);
await _verifyTransition(tester, nav);
await tester.tap(find.byKey(const Key('recommend-tab-new')));
expect(tapped, 'new');
});
}
+127
View File
@@ -0,0 +1,127 @@
// 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/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_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 {
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);
});
}
+65 -51
View File
@@ -313,7 +313,7 @@ void main() {
.first; .first;
expect(loadingOverlay, findsOneWidget); expect(loadingOverlay, findsOneWidget);
expect(tester.widget<IgnorePointer>(loadingOverlay).ignoring, isTrue); expect(tester.widget<IgnorePointer>(loadingOverlay).ignoring, isTrue);
expect(tester.getSize(loadingOverlay), const Size(40, 40)); expect(tester.getSize(loadingOverlay), const Size(56, 36));
expect( expect(
tester tester
.widget<AnimatedOpacity>( .widget<AnimatedOpacity>(
@@ -327,26 +327,29 @@ void main() {
); );
expect( expect(
tester.getSize(find.byKey(const Key('player-loading-surface'))), tester.getSize(find.byKey(const Key('player-loading-surface'))),
const Size(40, 40), const Size(56, 36),
); );
expect( expect(
tester.getSize(find.byKey(const Key('player-entry-loading'))), tester.getSize(find.byKey(const Key('player-entry-loading'))),
const Size(20, 20), const Size(48, 29),
); );
expect( expect(
tester find.descendant(
.widget<CircularProgressIndicator>( of: loadingOverlay,
find.byKey(const Key('player-entry-loading')), matching: find.byType(CircularProgressIndicator),
) ),
.color, findsNothing,
Colors.white, );
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>( final loadingSurface = tester.widget<Container>(
find.byKey(const Key('player-loading-surface')), find.byKey(const Key('player-loading-surface')),
); );
final decoration = loadingSurface.decoration! as BoxDecoration; expect(loadingSurface.decoration, isNull);
expect(decoration.color, AppColors.refreshIndicatorSurface);
expect(decoration.borderRadius, BorderRadius.circular(20));
expect( expect(
find.descendant(of: loadingOverlay, matching: find.byType(ColoredBox)), find.descendant(of: loadingOverlay, matching: find.byType(ColoredBox)),
findsNothing, findsNothing,
@@ -1271,6 +1274,22 @@ void main() {
expect(find.byKey(const Key('player-interaction-rail')), findsOneWidget); 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 { testWidgets('should_push_episodes_arrow_to_pill_right', (tester) async {
// 宽屏下文案不会撑满胶囊,才能验证箭头是否被推到最右侧。 // 宽屏下文案不会撑满胶囊,才能验证箭头是否被推到最右侧。
await _pumpApp(tester, size: const Size(900, 844)); await _pumpApp(tester, size: const Size(900, 844));
@@ -1371,9 +1390,9 @@ void main() {
final shareRect = tester.getRect( final shareRect = tester.getRect(
find.byKey(const Key('player-rail-share')), find.byKey(const Key('player-rail-share')),
); );
expect(likeRect.top - saveRect.bottom, closeTo(12, 0.1)); expect(likeRect.top - saveRect.bottom, closeTo(6, 0.1));
expect(commentsRect.top - likeRect.bottom, closeTo(12, 0.1)); expect(commentsRect.top - likeRect.bottom, closeTo(6, 0.1));
expect(shareRect.top - commentsRect.bottom, closeTo(12, 0.1)); expect(shareRect.top - commentsRect.bottom, closeTo(6, 0.1));
final saveButton = tester.widget<IconButton>( final saveButton = tester.widget<IconButton>(
find.descendant( find.descendant(
@@ -1405,7 +1424,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( final longCopy = Drama(
id: 'long-copy-demo', id: 'long-copy-demo',
title: 'Long Title', title: 'Long Title',
@@ -1428,7 +1449,7 @@ void main() {
await _pumpApp(tester, catalog: FakeCatalogRepository(dramas: [longCopy])); await _pumpApp(tester, catalog: FakeCatalogRepository(dramas: [longCopy]));
await _openSeriesPlayer(tester, feedIndex: 0); 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')); final synopsis = find.byKey(const Key('player-synopsis'));
expect( expect(
find.descendant(of: toggle, matching: find.text('More')), find.descendant(of: toggle, matching: find.text('More')),
@@ -1443,17 +1464,15 @@ void main() {
?.color, ?.color,
AppColors.textPrimary, AppColors.textPrimary,
); );
expect(tester.widget<Text>(synopsis).maxLines, 1); expect(tester.widget<Text>(synopsis).maxLines, 2);
expect(tester.widget<Text>(synopsis).overflow, TextOverflow.ellipsis); expect(tester.widget<Text>(synopsis).overflow, TextOverflow.ellipsis);
// 行尾「展开」与正文同一行,只留 6 的左边距。 final motion = tester.widget<AnimatedSize>(
expect( find.byKey(const Key('player-synopsis-size')),
tester
.widget<Padding>(
find.descendant(of: toggle, matching: find.byType(Padding)),
)
.padding,
const EdgeInsets.only(left: 6),
); );
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 synopsisRect = tester.getRect(synopsis);
final toggleRect = tester.getRect(toggle); final toggleRect = tester.getRect(toggle);
expect(toggleRect.left, greaterThan(synopsisRect.left)); expect(toggleRect.left, greaterThan(synopsisRect.left));
@@ -1463,24 +1482,24 @@ void main() {
); );
await tester.tap(toggle); 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(); await tester.pumpAndSettle();
expect( expect(
find.descendant(of: toggle, matching: find.text('Less')), find.descendant(of: toggle, matching: find.text('Less')),
findsOneWidget, findsOneWidget,
); );
expect( expect(
tester.widget<Text>(find.byKey(const Key('player-synopsis'))).maxLines, tester
.widget<Text>(find.byKey(const Key('player-synopsis-expanded')))
.maxLines,
isNull, isNull,
); );
final lessRect = tester.getRect(toggle); expect(tester.getRect(toggle), toggleRect);
final textRect = tester.getRect(find.byKey(const Key('player-synopsis'))); expect(tester.getSize(blockFinder).height, greaterThan(growingHeight));
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));
await tester.tap(toggle); await tester.tap(toggle);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@@ -1488,29 +1507,24 @@ void main() {
find.descendant(of: toggle, matching: find.text('More')), find.descendant(of: toggle, matching: find.text('More')),
findsOneWidget, 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, tester,
) async { ) async {
await _pumpApp(tester); await _pumpApp(tester);
await _openSeriesPlayer(tester); await _openSeriesPlayer(tester);
// 新 UI 把「第 n 集」与简介合并进同一个富文本,整行共用一套白字规格。
final line = tester.widget<Text>(find.byKey(const Key('player-synopsis'))); final line = tester.widget<Text>(find.byKey(const Key('player-synopsis')));
final style = line.style ?? const TextStyle(); final style = line.style ?? const TextStyle();
expect(style.fontSize, 12); expect(style.fontSize, 15);
expect(style.fontWeight, FontWeight.w500); expect(style.fontWeight, FontWeight.w400);
expect(style.color, AppColors.onPrimary); expect(style.color, AppColors.homeFeedVideoCopy);
expect(style.shadows ?? <Shadow>[], isEmpty);
final span = line.textSpan! as TextSpan; expect(line.textSpan!.toPlainText(), isNot(startsWith('Episode ')));
final separator = span.children!.whereType<TextSpan>().firstWhere( expect(line.textSpan!.toPlainText(), isNot(contains(' | ')));
(child) => (child.text ?? '').contains('|'), expect(find.byKey(const Key('player-episode-heading')), findsOneWidget);
);
expect(separator.style?.fontSize, 12);
expect(separator.style?.fontWeight, FontWeight.w400);
expect(separator.style?.color, AppColors.onPrimary.withValues(alpha: 0.45));
}); });
testWidgets('should_fit_long_title_and_synopsis_at_320_width', ( testWidgets('should_fit_long_title_and_synopsis_at_320_width', (
@@ -1563,7 +1577,7 @@ void main() {
tester.getRect(find.byKey(const Key('player-synopsis'))).right, tester.getRect(find.byKey(const Key('player-synopsis'))).right,
lessThanOrEqualTo(320 - 14 - 54), lessThanOrEqualTo(320 - 14 - 54),
); );
expect(synopsis.maxLines, 1); expect(synopsis.maxLines, 2);
expect(synopsis.overflow, TextOverflow.ellipsis); expect(synopsis.overflow, TextOverflow.ellipsis);
expect( expect(
tester.getRect(find.byKey(const Key('player-episodes-pill'))).right, tester.getRect(find.byKey(const Key('player-episodes-pill'))).right,
@@ -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: () {}),
),
),
),
),
);
}
@@ -0,0 +1,73 @@
// 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/l10n/app_localizations.dart';
import 'package:fruitshorts/pages/recommend/widgets/home_recommend_content.dart';
const _tag = '热播';
const _fallback = '上新';
const _posterWidth = 110.0;
const _posterHeight = 230.0;
void main() {
for (final tag in [_tag, '']) {
testWidgets(
'should_keep_badge_regular_when_using_${tag.isEmpty ? 'fallback' : 'tag'}',
(tester) async {
var taps = 0;
await tester.pumpWidget(_host(tag, () => taps++));
await tester.pumpAndSettle();
final label = find.text(tag.isEmpty ? _fallback : tag);
final text = tester.widget<Text>(label);
expect(text.style?.fontWeight, FontWeight.w400);
expect(text.style?.fontSize, 11);
expect(text.style?.color, AppColors.textPrimary);
final badge = tester.widget<Container>(
find.ancestor(of: label, matching: find.byType(Container)).first,
);
final decoration = badge.decoration! as BoxDecoration;
expect(decoration.color, AppColors.brandRed);
expect(decoration.borderRadius, BorderRadius.circular(6));
expect(decoration.boxShadow, isNull);
expect(
badge.padding,
const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
);
final position = tester.widget<Positioned>(
find.ancestor(of: label, matching: find.byType(Positioned)).first,
);
expect(position.left, 8);
expect(position.top, 8);
await tester.tap(label);
expect(taps, 1);
expect(tester.takeException(), isNull);
},
);
}
}
Widget _host(String tag, VoidCallback onTap) => MaterialApp(
locale: const Locale('zh'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: SizedBox(
width: _posterWidth,
height: _posterHeight,
child: HomePosterCard(
drama: Drama(
id: 'poster',
title: '测试短剧',
synopsis: '',
posterUrl: '',
tag: tag,
),
badge: _fallback,
onTap: onTap,
),
),
),
);
@@ -0,0 +1,105 @@
// Author: fruit.com.cn
// 职责:验证首页跨页动画的目标保留、连点打断和减少动画行为。
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/pages/recommend/home_tab_motion_controller.dart';
const _midpoint = Duration(milliseconds: 90);
const _finish = Duration(milliseconds: 300);
Widget _pager(HomeTabMotionController controller, List<int> accepted) =>
MaterialApp(
home: PageView(
controller: controller,
onPageChanged: (index) {
if (controller.acceptsSelection(index)) accepted.add(index);
},
children: const [
ColoredBox(color: Colors.black),
ColoredBox(color: Colors.red),
ColoredBox(color: Colors.grey),
],
),
);
void main() {
testWidgets(
'should_ignore_intermediate_page_when_clicking_across_two_pages',
(tester) async {
int? settled;
final controller = HomeTabMotionController(
onSettled: (index) => settled = index,
);
addTearDown(controller.dispose);
final accepted = <int>[];
await tester.pumpWidget(_pager(controller, accepted));
final transition = controller.selectPage(2, reduceMotion: false);
await tester.pump();
await tester.pump(_midpoint);
expect(controller.pagePosition, inExclusiveRange(0.0, 2.0));
expect(controller.acceptsSelection(1), isFalse);
expect(accepted, isNot(contains(1)));
await tester.pump(_finish);
await transition;
expect(controller.pagePosition, 2);
expect(settled, 2);
expect(controller.acceptsSelection(1), isTrue);
},
);
testWidgets('should_keep_last_target_when_clicks_interrupt_each_other', (
tester,
) async {
int? settled;
final controller = HomeTabMotionController(
onSettled: (index) => settled = index,
);
addTearDown(controller.dispose);
await tester.pumpWidget(_pager(controller, []));
final first = controller.selectPage(2, reduceMotion: false);
await tester.pump();
await tester.pump(_midpoint);
final last = controller.selectPage(0, reduceMotion: false);
await tester.pump();
expect(controller.acceptsSelection(2), isFalse);
await tester.pump(_finish);
await Future.wait([first, last]);
expect(controller.pagePosition, 0);
expect(settled, 0);
});
testWidgets('should_jump_to_target_when_reduced_motion_is_enabled', (
tester,
) async {
int? settled;
final controller = HomeTabMotionController(
onSettled: (index) => settled = index,
);
addTearDown(controller.dispose);
await tester.pumpWidget(_pager(controller, []));
await controller.selectPage(2, reduceMotion: true);
expect(controller.pagePosition, 2);
expect(settled, 2);
});
testWidgets('should_release_target_when_user_drag_interrupts_animation', (
tester,
) async {
final controller = HomeTabMotionController(onSettled: (_) {});
addTearDown(controller.dispose);
await tester.pumpWidget(_pager(controller, []));
final transition = controller.selectPage(2, reduceMotion: false);
await tester.pump();
await tester.pump(_midpoint);
final gesture = await tester.startGesture(
tester.getCenter(find.byType(PageView)),
);
await gesture.moveBy(const Offset(60, 0));
await tester.pump();
await transition;
expect(controller.acceptsSelection(0), isTrue);
expect(controller.acceptsSelection(1), isTrue);
await gesture.up();
await tester.pumpAndSettle();
});
}
+13 -6
View File
@@ -44,11 +44,11 @@ void main() {
final recommendText = tester.widget<Text>( final recommendText = tester.widget<Text>(
find.descendant( find.descendant(
of: find.byKey(const Key('recommend-tab-recommend')), of: find.byKey(const Key('recommend-tab-recommend')),
matching: find.text('Recommend'), matching: find.text('Featured'),
), ),
); );
expect(recommendText.style?.fontSize, 17); expect(recommendText.style?.fontSize, 18);
expect(recommendText.style?.fontWeight, FontWeight.w700); expect(recommendText.style?.fontWeight, FontWeight.w400);
expect(recommendText.style?.color, AppColors.textPrimary); expect(recommendText.style?.color, AppColors.textPrimary);
final playlistText = tester.widget<Text>( final playlistText = tester.widget<Text>(
find.descendant( find.descendant(
@@ -56,10 +56,17 @@ void main() {
matching: find.text('Playlists'), matching: find.text('Playlists'),
), ),
); );
expect(playlistText.style?.fontSize, 17); expect(playlistText.style?.fontSize, 18);
// 字形度量固定,未选中态靠绘制缩放和灰色区分,切换时不重新排版。
expect(playlistText.style?.fontWeight, FontWeight.w400); expect(playlistText.style?.fontWeight, FontWeight.w400);
// 新版顶部 tab 未选中态也用主文字色,选中态只靠字重和下划线区分。 expect(playlistText.style?.color, AppColors.homeTabInactive);
expect(playlistText.style?.color, AppColors.textPrimary); final playlistScale = tester.widget<Transform>(
find.descendant(
of: find.byKey(const Key('recommend-tab-playlist')),
matching: find.byType(Transform),
),
);
expect(playlistScale.transform.entry(0, 0), closeTo(0.9, 0.0001));
expect( expect(
tester tester
.widget<IconButton>(find.byKey(const Key('recommend-search'))) .widget<IconButton>(find.byKey(const Key('recommend-search')))
@@ -0,0 +1,146 @@
// 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/l10n/app_localizations.dart';
import 'package:fruitshorts/pages/recommend/recommend_types.dart';
import 'package:fruitshorts/pages/recommend/widgets/recommend_top_nav.dart';
const _pagePosition = 0.35;
const _duration = Duration(milliseconds: 90);
const _epsilon = 0.0001;
Widget _host({
double? position,
RecommendInnerTab tab = RecommendInnerTab.feed,
}) {
return MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: RecommendTopNav(
selected: tab,
pagePosition: position,
onSelected: (_) {},
onSearch: () {},
),
),
);
}
double _scale(WidgetTester tester, String tab) => tester
.widget<Transform>(
find.descendant(
of: find.byKey(Key('recommend-tab-$tab')),
matching: find.byType(Transform),
),
)
.transform
.entry(0, 0);
Color _underline(WidgetTester tester, String tab) {
final line = tester.widget<Container>(
find.byKey(Key('recommend-tab-$tab-underline')),
);
return (line.decoration! as BoxDecoration).color!;
}
void main() {
testWidgets(
'should_brighten_only_inactive_home_tabs_when_selection_changes',
(tester) async {
const inactive = Color(0xFFC7C7CC);
const codes = ['recommend', 'playlist', 'all'];
for (final tab in RecommendInnerTab.values) {
await tester.pumpWidget(
_host(position: tab.index.toDouble(), tab: tab),
);
for (final (index, code) in codes.indexed) {
final text = tester.widget<Text>(
find.descendant(
of: find.byKey(Key('recommend-tab-$code')),
matching: find.byType(Text),
),
);
expect(
text.style?.color,
index == tab.index ? AppColors.textPrimary : inactive,
);
}
}
await tester.pumpWidget(_host(position: _pagePosition));
final text = tester.widget<Text>(
find.descendant(
of: find.byKey(const Key('recommend-tab-playlist')),
matching: find.byType(Text),
),
);
expect(
text.style?.color,
Color.lerp(inactive, AppColors.textPrimary, _pagePosition),
);
expect(AppColors.textTabInactive, const Color(0xFFB0B0B0));
},
);
testWidgets('should_keep_regular_enlarged_labels_for_every_selection', (
tester,
) async {
for (final tab in RecommendInnerTab.values) {
await tester.pumpWidget(_host(position: tab.index.toDouble(), tab: tab));
for (final code in ['recommend', 'playlist', 'all']) {
final text = tester.widget<Text>(
find.descendant(
of: find.byKey(Key('recommend-tab-$code')),
matching: find.byType(Text),
),
);
expect(text.style?.fontSize, 18);
expect(text.style?.fontWeight, FontWeight.w400);
}
expect(tester.takeException(), isNull);
}
});
testWidgets('should_follow_page_position_without_delayed_label_animation', (
tester,
) async {
await tester.pumpWidget(_host(position: 0));
final first = tester.getRect(
find.byKey(const Key('recommend-tab-recommend')),
);
final second = tester.getRect(
find.byKey(const Key('recommend-tab-playlist')),
);
await tester.pumpWidget(
_host(position: _pagePosition, tab: RecommendInnerTab.playlists),
);
expect(_scale(tester, 'recommend'), closeTo(0.965, _epsilon));
expect(_scale(tester, 'playlist'), closeTo(0.935, _epsilon));
expect(_underline(tester, 'recommend').a, closeTo(0.65, _epsilon));
expect(_underline(tester, 'playlist').a, closeTo(0.35, _epsilon));
expect(_underline(tester, 'all').a, 0);
expect(
tester.getRect(find.byKey(const Key('recommend-tab-recommend'))),
first,
);
expect(
tester.getRect(find.byKey(const Key('recommend-tab-playlist'))),
second,
);
await tester.pumpWidget(_host(position: 0));
expect(_scale(tester, 'recommend'), 1);
expect(_underline(tester, 'recommend'), AppColors.brandRed);
});
testWidgets('should_fade_both_underlines_when_used_without_page_position', (
tester,
) async {
await tester.pumpWidget(_host());
await tester.pumpWidget(_host(tab: RecommendInnerTab.playlists));
await tester.pump(_duration);
expect(_underline(tester, 'recommend').a, inExclusiveRange(0.0, 1.0));
expect(_underline(tester, 'playlist').a, inExclusiveRange(0.0, 1.0));
});
}