diff --git a/lib/core/widgets/app_bottom_nav.dart b/lib/core/widgets/app_bottom_nav.dart index ecb85ea..dcfef24 100644 --- a/lib/core/widgets/app_bottom_nav.dart +++ b/lib/core/widgets/app_bottom_nav.dart @@ -18,6 +18,7 @@ class AppBottomNavItem { this.selected = false, this.assetPath, this.selectedAssetPath, + this.customIcon, }); final Key tabKey; @@ -25,6 +26,7 @@ class AppBottomNavItem { final IconData selectedIcon; final String? assetPath; final String? selectedAssetPath; + final Widget? customIcon; final String label; final VoidCallback onTap; final bool selected; @@ -93,26 +95,31 @@ class _NavDestination extends StatelessWidget { duration: const Duration(milliseconds: 160), curve: Curves.easeOut, scale: item.selected ? 1 : 0.96, - child: assetPath == null - ? Icon( - fallbackIcon, - size: AppDimens.navIcon, - color: color, - ) - : ColorFiltered( - colorFilter: ColorFilter.mode(color, BlendMode.srcIn), - child: Image.asset( - assetPath, - width: AppDimens.navIcon, - height: AppDimens.navIcon, - fit: BoxFit.contain, - errorBuilder: (_, _, _) => Icon( + child: + item.customIcon ?? + (assetPath == null + ? Icon( fallbackIcon, size: AppDimens.navIcon, color: color, - ), - ), - ), + ) + : ColorFiltered( + colorFilter: ColorFilter.mode( + color, + BlendMode.srcIn, + ), + child: Image.asset( + assetPath, + width: AppDimens.navIcon, + height: AppDimens.navIcon, + fit: BoxFit.contain, + errorBuilder: (_, _, _) => Icon( + fallbackIcon, + size: AppDimens.navIcon, + color: color, + ), + ), + )), ), const SizedBox(height: AppDimens.navLabelGap), Text( diff --git a/lib/core/widgets/app_root_tab.dart b/lib/core/widgets/app_root_tab.dart index 8c5aa98..e452908 100644 --- a/lib/core/widgets/app_root_tab.dart +++ b/lib/core/widgets/app_root_tab.dart @@ -2,8 +2,10 @@ import 'package:flutter/material.dart'; +import '../theme/app_dimens.dart'; import '../../l10n/app_localizations.dart'; import 'app_bottom_nav.dart'; +import 'root_nav_glyph.dart'; /// 客户端根导航的固定顺序。 enum AppRootTab { @@ -16,6 +18,13 @@ enum AppRootTab { } extension AppRootTabUi on AppRootTab { + RootNavGlyphKind get glyphKind => switch (this) { + AppRootTab.home => RootNavGlyphKind.home, + AppRootTab.recommend => RootNavGlyphKind.recommend, + AppRootTab.following => RootNavGlyphKind.following, + AppRootTab.mine => RootNavGlyphKind.mine, + }; + Key get tabKey => switch (this) { AppRootTab.home => const Key('tab-home'), AppRootTab.recommend => const Key('tab-recommend'), @@ -71,6 +80,11 @@ class AppRootBottomNav extends StatelessWidget { selectedIcon: tab.selectedIcon, label: tab.label(l10n), selected: tab == selected, + customIcon: RootNavGlyph( + kind: tab.glyphKind, + selected: tab == selected, + size: AppDimens.navIcon, + ), onTap: () => onSelected(tab), ), ], diff --git a/lib/core/widgets/root_nav_glyph.dart b/lib/core/widgets/root_nav_glyph.dart new file mode 100644 index 0000000..419da8f --- /dev/null +++ b/lib/core/widgets/root_nav_glyph.dart @@ -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; +} diff --git a/lib/pages/player/widgets/player_loading_overlay.dart b/lib/pages/player/widgets/player_loading_overlay.dart index a7ce449..5e631e4 100644 --- a/lib/pages/player/widgets/player_loading_overlay.dart +++ b/lib/pages/player/widgets/player_loading_overlay.dart @@ -3,8 +3,19 @@ import 'package:flutter/material.dart'; import '../../../core/theme/app_colors.dart'; -import '../../../core/theme/app_dimens.dart'; +const _fadeDuration = Duration(milliseconds: 140); +const _motionDuration = Duration(milliseconds: 1350); +const _surfaceSize = Size(56, 36); +const _logoSize = Size(48, 29); +const _minimumScale = 0.98; +const _staticProgress = 0.5; +const _waveMultiplier = 2.0; +const _sweepStart = -1.2; +const _sweepTravel = 3.4; +const _sweepFeather = 0.16; + +/// 剧集播放页首帧出现前的非阻塞 YMB 品牌加载层。 class PlayerLoadingOverlay extends StatelessWidget { const PlayerLoadingOverlay({ super.key, @@ -19,27 +30,124 @@ class PlayerLoadingOverlay extends StatelessWidget { Widget build(BuildContext context) { return AnimatedOpacity( opacity: opacity, - duration: const Duration(milliseconds: 140), + duration: _fadeDuration, curve: Curves.easeOut, onEnd: onFadeEnd, child: Container( key: const Key('player-loading-surface'), - width: AppDimens.refreshIndicatorSize, - height: AppDimens.refreshIndicatorSize, - decoration: BoxDecoration( - color: AppColors.refreshIndicatorSurface, - borderRadius: BorderRadius.circular(AppDimens.refreshIndicatorRadius), - ), + width: _surfaceSize.width, + height: _surfaceSize.height, alignment: Alignment.center, - child: const SizedBox.square( - dimension: AppDimens.refreshSpinnerSize, - child: CircularProgressIndicator( - key: Key('player-entry-loading'), - color: Colors.white, - strokeWidth: AppDimens.refreshSpinnerStroke, - ), - ), + child: const _YmbLoadingMark(), ), ); } } + +class _YmbLoadingMark extends StatefulWidget { + const _YmbLoadingMark(); + + @override + State<_YmbLoadingMark> createState() => _YmbLoadingMarkState(); +} + +class _YmbLoadingMarkState extends State<_YmbLoadingMark> + with SingleTickerProviderStateMixin, WidgetsBindingObserver { + late final AnimationController _controller; + bool _disableAnimations = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + _controller = AnimationController(vsync: this, duration: _motionDuration); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final disableAnimations = MediaQuery.disableAnimationsOf(context); + if (_disableAnimations == disableAnimations && _controller.isAnimating) { + return; + } + _disableAnimations = disableAnimations; + _syncMotion(WidgetsBinding.instance.lifecycleState); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + _syncMotion(state); + } + + void _syncMotion(AppLifecycleState? state) { + if (_disableAnimations) { + _controller + ..stop() + ..value = _staticProgress; + return; + } + if (state == null || state == AppLifecycleState.resumed) { + if (!_controller.isAnimating) { + _controller.repeat(); + } + return; + } + _controller.stop(); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _controller, + builder: (context, child) { + final progress = _controller.value; + return _buildLogo(progress); + }, + ); + } + + Widget _buildLogo(double progress) { + final pulse = Curves.easeInOut.transform(_triangleWave(progress)); + final scale = _minimumScale + (1 - _minimumScale) * pulse; + return Transform.scale( + key: const Key('player-entry-loading-motion'), + scale: scale, + child: ShaderMask( + blendMode: BlendMode.srcATop, + shaderCallback: (bounds) => + _logoGradient(progress).createShader(bounds), + child: Image.asset( + 'assets/icon/splash_logo.png', + key: const Key('player-entry-loading'), + width: _logoSize.width, + height: _logoSize.height, + fit: BoxFit.contain, + ), + ), + ); + } + + LinearGradient _logoGradient(double progress) { + final center = _sweepStart + progress * _sweepTravel; + return LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: const [ + Colors.white, + Colors.white, + AppColors.brandRed, + Colors.white, + ], + stops: [0, (center - _sweepFeather).clamp(0, 1), center.clamp(0, 1), 1], + ); + } + + double _triangleWave(double value) => 1 - (value * _waveMultiplier - 1).abs(); +} diff --git a/lib/pages/recommend/widgets/recommend_channel_nav.dart b/lib/pages/recommend/widgets/recommend_channel_nav.dart index 348def9..f36c3f8 100644 --- a/lib/pages/recommend/widgets/recommend_channel_nav.dart +++ b/lib/pages/recommend/widgets/recommend_channel_nav.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import '../../../core/data/models/content_category.dart'; import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_dimens.dart'; import '../../../core/widgets/animated_nav_label.dart'; import '../../../l10n/app_localizations.dart'; import 'search_gift_glyphs.dart'; @@ -11,7 +12,7 @@ import 'search_gift_glyphs.dart'; const double _navHeight = 56; const double _tabGap = 20; const double _tabFont = 17; -const double _searchHitSize = 40; +const double _searchHitSize = AppDimens.tapTarget; const double _searchIconSize = 26; const double _giftIconSize = 26; const double _underlineWidth = 26; diff --git a/lib/pages/recommend/widgets/recommend_top_nav.dart b/lib/pages/recommend/widgets/recommend_top_nav.dart index 21a3582..0b247eb 100644 --- a/lib/pages/recommend/widgets/recommend_top_nav.dart +++ b/lib/pages/recommend/widgets/recommend_top_nav.dart @@ -39,7 +39,7 @@ class RecommendTopNav extends StatelessWidget { /// 首页横向页面的连续位置,用于文字和下划线跟手过渡。 final double? pagePosition; - /// 右上角金色礼物盒(钱包入口);为空时不显示。 + /// 右上角礼包入口;为空时不显示。 final VoidCallback? onGift; @override diff --git a/lib/pages/recommend/widgets/search_gift_glyphs.dart b/lib/pages/recommend/widgets/search_gift_glyphs.dart index 721953a..76f7aae 100644 --- a/lib/pages/recommend/widgets/search_gift_glyphs.dart +++ b/lib/pages/recommend/widgets/search_gift_glyphs.dart @@ -50,8 +50,12 @@ class _SearchPainter extends CustomPainter { final center = Offset(circleDiameter / 2, circleDiameter / 2); canvas.drawCircle(center, circleDiameter / 2 - stroke / 2, paint); - final start = center + Offset.fromDirection(direction, circleDiameter / 2 - stroke * 0.2); - final end = center + Offset.fromDirection(direction, circleDiameter / 2 + size.width * 0.12); + final start = + center + + Offset.fromDirection(direction, circleDiameter / 2 - stroke * 0.2); + final end = + center + + Offset.fromDirection(direction, circleDiameter / 2 + size.width * 0.12); canvas.drawLine(start, end, paint); } @@ -60,7 +64,7 @@ class _SearchPainter extends CustomPainter { oldDelegate.color != color; } -/// emoji 礼盒(红盒金丝带,随系统 emoji 风格渲染)。 +/// YMB 品牌礼盒静态图标,使用玫红礼盒与金色丝带。 class GiftGlyph extends StatelessWidget { const GiftGlyph({super.key, required this.size}); @@ -68,11 +72,94 @@ class GiftGlyph extends StatelessWidget { @override Widget build(BuildContext context) { - return SizedBox.square( - dimension: size, - child: FittedBox( - child: Text('🎁', style: TextStyle(fontSize: size, height: 1)), - ), - ); + return CustomPaint(size: Size.square(size), painter: _GiftPainter()); } } + +class _GiftPainter extends CustomPainter { + const _GiftPainter(); + + static const double _designSize = 26; + + @override + void paint(Canvas canvas, Size size) { + canvas.save(); + canvas.scale(size.width / _designSize); + _paintBox(canvas); + _paintRibbon(canvas); + _paintBow(canvas); + canvas.restore(); + } + + void _paintBox(Canvas canvas) { + final body = RRect.fromRectAndRadius( + const Rect.fromLTRB(4, 13, 22, 23), + const Radius.circular(2), + ); + canvas.drawRRect( + body, + Paint() + ..shader = const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFFFF5C82), Color(0xFFEF315D)], + ).createShader(const Rect.fromLTRB(4, 13, 22, 23)), + ); + canvas.drawRRect( + RRect.fromRectAndRadius( + const Rect.fromLTRB(3, 10, 23, 14), + const Radius.circular(1.5), + ), + Paint() + ..shader = const LinearGradient( + colors: [Color(0xFFFF7190), Color(0xFFF52E5C)], + ).createShader(const Rect.fromLTRB(3, 10, 23, 14)), + ); + } + + void _paintRibbon(Canvas canvas) { + final ribbon = Paint() + ..shader = const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFFFFFFA8), Color(0xFFFFD52F), Color(0xFFFFB900)], + ).createShader(const Rect.fromLTRB(10.5, 10, 15.5, 23)); + canvas.drawRect(const Rect.fromLTRB(10.5, 13, 15.5, 23), ribbon); + canvas.drawRect(const Rect.fromLTRB(3, 10.5, 23, 14), ribbon); + canvas.drawLine( + const Offset(11, 14), + const Offset(11, 22), + Paint() + ..color = const Color(0xFFFFFFAE) + ..strokeWidth = 0.6, + ); + } + + void _paintBow(Canvas canvas) { + final gold = Paint() + ..shader = const LinearGradient( + colors: [Color(0xFFFFFFA8), Color(0xFFFFD52F), Color(0xFFFFB900)], + ).createShader(const Rect.fromLTRB(5, 1, 21, 12)); + final tails = Path() + ..moveTo(11.8, 7) + ..lineTo(15, 7) + ..lineTo(17, 13) + ..lineTo(14.1, 12) + ..lineTo(12.6, 14) + ..lineTo(10.8, 8) + ..close(); + canvas.drawPath(tails, gold); + final bow = Path() + ..moveTo(13, 7) + ..cubicTo(5.2, 0, 5.2, 4.8, 7.5, 6.7) + ..cubicTo(9, 8, 11.5, 8, 13, 7) + ..cubicTo(14.5, 8, 17, 8, 18.5, 6.7) + ..cubicTo(20.8, 4.8, 20.8, 0, 13, 7) + ..close(); + canvas.drawPath(bow, gold); + canvas.drawCircle(const Offset(13, 7), 1.55, gold); + } + + @override + bool shouldRepaint(covariant _GiftPainter oldDelegate) => false; +} diff --git a/test/home/home_nav_ui_test.dart b/test/home/home_nav_ui_test.dart index 66475e8..e91d891 100644 --- a/test/home/home_nav_ui_test.dart +++ b/test/home/home_nav_ui_test.dart @@ -11,6 +11,7 @@ import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart import 'package:fruitshorts/core/theme/app_colors.dart'; import 'package:fruitshorts/core/theme/app_dimens.dart'; import 'package:fruitshorts/core/widgets/app_bottom_nav.dart'; +import 'package:fruitshorts/core/widgets/root_nav_glyph.dart'; import 'package:fruitshorts/pages/following/following_page.dart'; import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; @@ -138,18 +139,20 @@ void main() { .duration, lessThanOrEqualTo(const Duration(milliseconds: 200)), ); - final selectedHomeIcon = tester.widget( + final selectedHomeIcon = tester.widget( find.descendant( of: find.byKey(const Key('tab-home')), - matching: find.byIcon(Icons.home_rounded), + matching: find.byType(RootNavGlyph), ), ); - expect(selectedHomeIcon.color, AppColors.navSelected); + expect(selectedHomeIcon.kind, RootNavGlyphKind.home); + expect(selectedHomeIcon.selected, isTrue); + expect(selectedHomeIcon.size, AppDimens.navIcon); expect( tester.getSize( find.descendant( of: find.byKey(const Key('tab-home')), - matching: find.byIcon(Icons.home_rounded), + matching: find.byType(RootNavGlyph), ), ), const Size.square(AppDimens.navIcon), @@ -157,21 +160,21 @@ void main() { expect( find.descendant( of: find.byKey(const Key('tab-recommend')), - matching: find.byIcon(Icons.smart_display_outlined), + matching: find.byType(RootNavGlyph), ), findsOneWidget, ); expect( find.descendant( of: find.byKey(const Key('tab-follow')), - matching: find.byIcon(Icons.bookmark_border_rounded), + matching: find.byType(RootNavGlyph), ), findsOneWidget, ); expect( find.descendant( of: find.byKey(const Key('tab-mine')), - matching: find.byIcon(Icons.person_outline_rounded), + matching: find.byType(RootNavGlyph), ), findsOneWidget, ); @@ -188,7 +191,7 @@ void main() { expect( find.descendant( of: find.byKey(const Key('tab-recommend')), - matching: find.byIcon(Icons.smart_display_rounded), + matching: find.byType(RootNavGlyph), ), findsOneWidget, ); @@ -219,20 +222,20 @@ void main() { expect( find.descendant( of: find.byKey(const Key('tab-home')), - matching: find.byIcon(Icons.home_outlined), + matching: find.byType(RootNavGlyph), ), findsOneWidget, ); expect( tester - .widget( + .widget( find.descendant( of: find.byKey(const Key('tab-home')), - matching: find.byIcon(Icons.home_outlined), + matching: find.byType(RootNavGlyph), ), ) - .color, - AppColors.navUnselected, + .selected, + isFalse, ); }); @@ -270,7 +273,7 @@ void main() { expect( find.descendant( of: find.byKey(const Key('tab-follow')), - matching: find.byIcon(Icons.bookmark_rounded), + matching: find.byType(RootNavGlyph), ), findsOneWidget, ); diff --git a/test/home/root_nav_glyph_test.dart b/test/home/root_nav_glyph_test.dart new file mode 100644 index 0000000..993a433 --- /dev/null +++ b/test/home/root_nav_glyph_test.dart @@ -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(find.byType(RootNavGlyph)) + .toList(); + expect(glyphs.map((glyph) => glyph.kind), RootNavGlyphKind.values); + expect(glyphs.map((glyph) => glyph.selected), [ + true, + false, + false, + false, + ]); + }, + ); +} diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 42dd98e..d5fe0f6 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -313,7 +313,7 @@ void main() { .first; expect(loadingOverlay, findsOneWidget); expect(tester.widget(loadingOverlay).ignoring, isTrue); - expect(tester.getSize(loadingOverlay), const Size(40, 40)); + expect(tester.getSize(loadingOverlay), const Size(56, 36)); expect( tester .widget( @@ -327,26 +327,29 @@ void main() { ); expect( tester.getSize(find.byKey(const Key('player-loading-surface'))), - const Size(40, 40), + const Size(56, 36), ); expect( tester.getSize(find.byKey(const Key('player-entry-loading'))), - const Size(20, 20), + const Size(48, 29), ); expect( - tester - .widget( - find.byKey(const Key('player-entry-loading')), - ) - .color, - Colors.white, + find.descendant( + of: loadingOverlay, + matching: find.byType(CircularProgressIndicator), + ), + findsNothing, + ); + expect(find.text('Loading'), findsNothing); + expect(find.byKey(const Key('player-entry-loading-track')), findsNothing); + expect( + find.byKey(const Key('player-entry-loading-highlight')), + findsNothing, ); final loadingSurface = tester.widget( find.byKey(const Key('player-loading-surface')), ); - final decoration = loadingSurface.decoration! as BoxDecoration; - expect(decoration.color, AppColors.refreshIndicatorSurface); - expect(decoration.borderRadius, BorderRadius.circular(20)); + expect(loadingSurface.decoration, isNull); expect( find.descendant(of: loadingOverlay, matching: find.byType(ColoredBox)), findsNothing, diff --git a/test/player/player_loading_overlay_test.dart b/test/player/player_loading_overlay_test.dart new file mode 100644 index 0000000..49b079c --- /dev/null +++ b/test/player/player_loading_overlay_test.dart @@ -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(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(find.byKey(_motion)).transform.storage.first; +} + +Future _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: () {}), + ), + ), + ), + ), + ); +}