From 335e5dc28614b67ec21aa95fa580d62ecc5d2f74 Mon Sep 17 00:00:00 2001 From: Vicky <10212746+haifeng-jiang@user.noreply.gitee.com> Date: Fri, 30 Oct 2026 22:53:15 +0800 Subject: [PATCH] =?UTF-8?q?feat(home):=20=E9=A1=B6=E9=83=A8=E5=AF=BC?= =?UTF-8?q?=E8=88=AA=E9=9A=8F=E7=BF=BB=E9=A1=B5=E8=81=94=E5=8A=A8=E5=B9=B6?= =?UTF-8?q?=E6=8A=8A=E6=8E=A8=E8=8D=90=E6=9B=B4=E5=90=8D=E4=B8=BA=E7=B2=BE?= =?UTF-8?q?=E9=80=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/theme/app_colors.dart | 4 + lib/core/widgets/animated_nav_label.dart | 71 +++++++++ lib/core/widgets/app_root_tab.dart | 4 +- lib/l10n/app_en.arb | 2 +- lib/l10n/app_id.arb | 2 +- lib/l10n/app_localizations.dart | 2 +- lib/l10n/app_localizations_en.dart | 2 +- lib/l10n/app_localizations_id.dart | 2 +- lib/l10n/app_localizations_zh.dart | 2 +- lib/l10n/app_zh.arb | 2 +- lib/pages/home/widgets/home_grid_header.dart | 20 +-- .../recommend/home_tab_motion_controller.dart | 52 ++++++ lib/pages/recommend/recommend_page.dart | 142 +++-------------- lib/pages/recommend/recommend_page_parts.dart | 116 ++++++++++++++ .../widgets/home_recommend_content.dart | 2 +- .../widgets/recommend_channel_nav.dart | 32 ++-- .../recommend/widgets/recommend_top_nav.dart | 92 +++++++---- test/core/animated_nav_label_test.dart | 110 +++++++++++++ test/home/home_featured_label_test.dart | 66 ++++++++ test/home/home_grid_layout_test.dart | 46 +++++- test/home/home_nav_ui_test.dart | 9 +- test/home/home_preview_layout_test.dart | 37 +++-- test/home/top_navigation_motion_test.dart | 149 ++++++++++++++++++ .../home_poster_badge_weight_test.dart | 73 +++++++++ .../home_tab_motion_controller_test.dart | 105 ++++++++++++ test/recommend/recommend_sections_test.dart | 19 ++- .../recommend_top_nav_motion_test.dart | 146 +++++++++++++++++ 27 files changed, 1095 insertions(+), 214 deletions(-) create mode 100644 lib/core/widgets/animated_nav_label.dart create mode 100644 lib/pages/recommend/home_tab_motion_controller.dart create mode 100644 lib/pages/recommend/recommend_page_parts.dart create mode 100644 test/core/animated_nav_label_test.dart create mode 100644 test/home/home_featured_label_test.dart create mode 100644 test/home/top_navigation_motion_test.dart create mode 100644 test/recommend/home_poster_badge_weight_test.dart create mode 100644 test/recommend/home_tab_motion_controller_test.dart create mode 100644 test/recommend/recommend_top_nav_motion_test.dart diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart index d9376b4..cef7b68 100644 --- a/lib/core/theme/app_colors.dart +++ b/lib/core/theme/app_colors.dart @@ -153,6 +153,10 @@ abstract final class AppColors { /// 列表底部「没有更多了」这类弱提示。 static const textHint = Color(0xFF5C5C60); static const textTabInactive = Color(0xFFB0B0B0); + + /// 首页顶部三项导航未选中文字,略暗于选中白色但保持清晰。 + static const homeTabInactive = Color(0xFFC7C7CC); + static const textFilterIcon = Color(0xFF7A7A7E); static const searchResultMeta = Color(0xFF9E9E9E); diff --git a/lib/core/widgets/animated_nav_label.dart b/lib/core/widgets/animated_nav_label.dart new file mode 100644 index 0000000..99b7ccc --- /dev/null +++ b/lib/core/widgets/animated_nav_label.dart @@ -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( + 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, + ), + ), + ), + ); +} diff --git a/lib/core/widgets/app_root_tab.dart b/lib/core/widgets/app_root_tab.dart index c5a9c3f..8c5aa98 100644 --- a/lib/core/widgets/app_root_tab.dart +++ b/lib/core/widgets/app_root_tab.dart @@ -25,14 +25,14 @@ extension AppRootTabUi on AppRootTab { IconData get icon => switch (this) { AppRootTab.home => Icons.home_outlined, - AppRootTab.recommend => Icons.celebration_outlined, + AppRootTab.recommend => Icons.smart_display_outlined, AppRootTab.following => Icons.bookmark_border_rounded, AppRootTab.mine => Icons.person_outline_rounded, }; IconData get selectedIcon => switch (this) { AppRootTab.home => Icons.home_rounded, - AppRootTab.recommend => Icons.celebration_rounded, + AppRootTab.recommend => Icons.smart_display_rounded, AppRootTab.following => Icons.bookmark_rounded, AppRootTab.mine => Icons.person_rounded, }; diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 2dfbee6..8440a85 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -761,7 +761,7 @@ "homeBadgeNew": "New", "homeBadgeAnime": "Anime", "emptyContent": "No content yet", - "recommendTopTab": "Recommend", + "recommendTopTab": "Featured", "playlistTopTab": "Playlists", "allDramasTopTab": "All Dramas", "weeklyHitsTitle": "This Week's Hits", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index bd85997..21c9b2b 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -723,7 +723,7 @@ "homeBadgeNew": "Baru", "homeBadgeAnime": "Anime", "emptyContent": "Belum ada konten", - "recommendTopTab": "Rekomendasi", + "recommendTopTab": "Unggulan", "playlistTopTab": "Daftar Putar", "allDramasTopTab": "Semua", "weeklyHitsTitle": "Hits Minggu Ini", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 8e18baf..01d871a 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -3271,7 +3271,7 @@ abstract class AppLocalizations { /// No description provided for @recommendTopTab. /// /// In en, this message translates to: - /// **'Recommend'** + /// **'Featured'** String get recommendTopTab; /// No description provided for @playlistTopTab. diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index f6bc374..6ec70d6 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1711,7 +1711,7 @@ class AppLocalizationsEn extends AppLocalizations { String get emptyContent => 'No content yet'; @override - String get recommendTopTab => 'Recommend'; + String get recommendTopTab => 'Featured'; @override String get playlistTopTab => 'Playlists'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 6b0562a..d09f864 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -1711,7 +1711,7 @@ class AppLocalizationsId extends AppLocalizations { String get emptyContent => 'Belum ada konten'; @override - String get recommendTopTab => 'Rekomendasi'; + String get recommendTopTab => 'Unggulan'; @override String get playlistTopTab => 'Daftar Putar'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index a18f1c7..bd48256 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -1686,7 +1686,7 @@ class AppLocalizationsZh extends AppLocalizations { String get emptyContent => '暂无内容'; @override - String get recommendTopTab => '推荐'; + String get recommendTopTab => '精选'; @override String get playlistTopTab => '剧单'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 027e020..982c85f 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -685,7 +685,7 @@ "homeBadgeNew": "新剧", "homeBadgeAnime": "漫剧", "emptyContent": "暂无内容", - "recommendTopTab": "推荐", + "recommendTopTab": "精选", "playlistTopTab": "剧单", "allDramasTopTab": "全部短剧", "weeklyHitsTitle": "本週爆款推薦", diff --git a/lib/pages/home/widgets/home_grid_header.dart b/lib/pages/home/widgets/home_grid_header.dart index 9a89ce5..4110c6d 100644 --- a/lib/pages/home/widgets/home_grid_header.dart +++ b/lib/pages/home/widgets/home_grid_header.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import '../../../core/formatting/count_format.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'; enum HomePrimaryTab { @@ -323,21 +324,16 @@ class _CategoryBarState extends State<_CategoryBar> { height: AppDimens.homePrimaryRowHeight, child: Align( alignment: Alignment.bottomLeft, - child: AnimatedDefaultTextStyle( + child: AnimatedNavLabel( key: Key('home-primary-label-${category.categoryId}'), - duration: AppDimens.homePrimaryTabAnimationDuration, - curve: Curves.easeOut, - style: TextStyle( - color: selected - ? AppColors.navSelected - : AppColors.textTabInactive, - fontSize: selected - ? AppDimens.homePrimaryTabSelectedFont - : AppDimens.homePrimaryTabFont, - fontWeight: selected ? FontWeight.w700 : FontWeight.w400, + label: category.label, + selected: selected, + alignment: Alignment.bottomLeft, + style: const TextStyle( + color: AppColors.navSelected, + fontSize: AppDimens.homePrimaryTabSelectedFont, letterSpacing: 0, ), - child: Text(category.label, textAlign: TextAlign.left), ), ), ), diff --git a/lib/pages/recommend/home_tab_motion_controller.dart b/lib/pages/recommend/home_tab_motion_controller.dart new file mode 100644 index 0000000..5974e59 --- /dev/null +++ b/lib/pages/recommend/home_tab_motion_controller.dart @@ -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 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 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(); + } +} diff --git a/lib/pages/recommend/recommend_page.dart b/lib/pages/recommend/recommend_page.dart index b2f69a5..3087aa4 100644 --- a/lib/pages/recommend/recommend_page.dart +++ b/lib/pages/recommend/recommend_page.dart @@ -20,12 +20,15 @@ import '../../l10n/app_localizations.dart'; import '../home/home_feed_channel.dart'; import '../player/open_player.dart'; import '../player/player_view.dart'; +import 'home_tab_motion_controller.dart'; import 'recommend_types.dart'; import 'widgets/all_dramas_filter_grid.dart'; import 'widgets/playlist_detail_page.dart'; import 'widgets/recommend_sections_view.dart'; import 'widgets/recommend_top_nav.dart'; +part 'recommend_page_parts.dart'; + class RecommendPage extends StatefulWidget { const RecommendPage({ super.key, @@ -60,7 +63,9 @@ class _RecommendPageState extends State static const int _maxRecommendRetries = 3; RecommendInnerTab _tab = RecommendInnerTab.feed; - final PageController _pageController = PageController(); + late final _pageController = HomeTabMotionController( + onSettled: _onPageChanged, + ); int _scrubCancelRequest = 0; bool _recommendDataRefreshScheduled = false; bool _recommendRefreshInFlight = false; @@ -274,12 +279,16 @@ class _RecommendPageState extends State right: 0, child: SafeArea( bottom: false, - child: RecommendTopNav( - selected: _tab, - onVideo: _tab == RecommendInnerTab.feed, - onSelected: _selectTab, - onSearch: _handleSearch, - onGift: widget.onOpenWallet, + child: ListenableBuilder( + listenable: _pageController, + builder: (context, _) => RecommendTopNav( + selected: _tab, + pagePosition: _pageController.pagePosition, + onVideo: _tab == RecommendInnerTab.feed, + onSelected: _selectTab, + onSearch: _handleSearch, + onGift: widget.onOpenWallet, + ), ), ), ), @@ -401,18 +410,16 @@ class _RecommendPageState extends State _tab = tab; _scrubCancelRequest++; }); - if (_pageController.hasClients) { - unawaited( - _pageController.animateToPage( - tab.index, - duration: const Duration(milliseconds: 220), - curve: Curves.easeOut, - ), - ); - } + unawaited( + _pageController.selectPage( + tab.index, + reduceMotion: MediaQuery.disableAnimationsOf(context), + ), + ); } void _onPageChanged(int index) { + if (!_pageController.acceptsSelection(index)) return; final next = RecommendInnerTab.values[index]; if (next == _tab) return; setState(() { @@ -484,110 +491,7 @@ class _RecommendPageState extends State ); } - (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 _openDrama(Drama drama, {String? episodeId}) async { 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; - } -} diff --git a/lib/pages/recommend/recommend_page_parts.dart b/lib/pages/recommend/recommend_page_parts.dart new file mode 100644 index 0000000..1a03f42 --- /dev/null +++ b/lib/pages/recommend/recommend_page_parts.dart @@ -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, + ), +}; diff --git a/lib/pages/recommend/widgets/home_recommend_content.dart b/lib/pages/recommend/widgets/home_recommend_content.dart index cbe6933..181c416 100644 --- a/lib/pages/recommend/widgets/home_recommend_content.dart +++ b/lib/pages/recommend/widgets/home_recommend_content.dart @@ -122,7 +122,7 @@ class HomePosterCard extends StatelessWidget { style: const TextStyle( color: AppColors.textPrimary, fontSize: 11, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w400, height: 1.1, ), ), diff --git a/lib/pages/recommend/widgets/recommend_channel_nav.dart b/lib/pages/recommend/widgets/recommend_channel_nav.dart index 3d69c7a..348def9 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/widgets/animated_nav_label.dart'; import '../../../l10n/app_localizations.dart'; import 'search_gift_glyphs.dart'; @@ -16,7 +17,7 @@ const double _giftIconSize = 26; const double _underlineWidth = 26; const double _underlineHeight = 3; -/// 推荐页(礼花 tab)顶部频道导航:频道全部来自分类接口,右侧搜索/礼物。 +/// 推荐页顶部频道导航:频道全部来自分类接口,右侧搜索/礼物。 class RecommendChannelNav extends StatefulWidget { const RecommendChannelNav({ super.key, @@ -49,8 +50,7 @@ class _RecommendChannelNavState extends State { final GlobalKey _scrollViewKey = GlobalKey(); final Map _tabKeys = {}; - GlobalKey _keyFor(String id) => - _tabKeys.putIfAbsent(id, () => GlobalKey()); + GlobalKey _keyFor(String id) => _tabKeys.putIfAbsent(id, () => GlobalKey()); @override void didUpdateWidget(covariant RecommendChannelNav oldWidget) { @@ -82,9 +82,10 @@ class _RecommendChannelNavState extends State { final tabLeft = tabBox.localToGlobal(Offset.zero).dx; final relative = tabLeft - viewportLeft; final position = _scrollController.position; - final target = - (position.pixels + relative - _revealLeftInset) - .clamp(0.0, position.maxScrollExtent); + final target = (position.pixels + relative - _revealLeftInset).clamp( + 0.0, + position.maxScrollExtent, + ); if ((target - position.pixels).abs() < 0.5) return; _scrollController.animateTo( target, @@ -147,7 +148,10 @@ class _RecommendChannelNavState extends State { ), iconSize: _searchIconSize, color: AppColors.textPrimary, - icon: SearchGlyph(size: _searchIconSize, color: AppColors.textPrimary), + icon: SearchGlyph( + size: _searchIconSize, + color: AppColors.textPrimary, + ), ), ), if (widget.onGift != null) @@ -188,18 +192,16 @@ class _RecommendChannelNavState extends State { children: [ // 补偿文字下方的 4pt 间距 + 3pt 下划线,让文字视觉中心与右侧图标对齐。 const SizedBox(height: 4 + _underlineHeight), - Text( - label, - maxLines: 1, - style: TextStyle( + AnimatedNavLabel( + label: label, + selected: selected, + style: const TextStyle( color: AppColors.textPrimary, fontSize: _tabFont, - fontWeight: selected ? FontWeight.w700 : FontWeight.w400, + fontWeight: FontWeight.w400, letterSpacing: 0, height: 1.0, - shadows: const [ - Shadow(color: AppColors.mediaBlack, blurRadius: 6), - ], + shadows: [Shadow(color: AppColors.mediaBlack, blurRadius: 6)], ), ), const SizedBox(height: 4), diff --git a/lib/pages/recommend/widgets/recommend_top_nav.dart b/lib/pages/recommend/widgets/recommend_top_nav.dart index 3fd9b47..21a3582 100644 --- a/lib/pages/recommend/widgets/recommend_top_nav.dart +++ b/lib/pages/recommend/widgets/recommend_top_nav.dart @@ -4,13 +4,14 @@ import 'package:flutter/material.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 '../recommend_types.dart'; import 'search_gift_glyphs.dart'; const double _navHeight = 56; const double _tabGap = 20; -const double _tabFont = 17; +const double _tabFont = 18; const double _maxNavigationTextScale = 1.3; const double _searchIconSize = 26; const double _giftIconSize = 26; @@ -25,6 +26,7 @@ class RecommendTopNav extends StatelessWidget { required this.onSearch, this.onVideo = false, this.onGift, + this.pagePosition, }); static const double height = _navHeight; @@ -34,6 +36,9 @@ class RecommendTopNav extends StatelessWidget { final VoidCallback onSearch; final bool onVideo; + /// 首页横向页面的连续位置,用于文字和下划线跟手过渡。 + final double? pagePosition; + /// 右上角金色礼物盒(钱包入口);为空时不显示。 final VoidCallback? onGift; @@ -65,14 +70,7 @@ class RecommendTopNav extends StatelessWidget { children: [ for (var index = 0; index < items.length; index++) ...[ if (index > 0) const SizedBox(width: _tabGap), - _tab( - 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), - ), + _tab(items[index], shadows, context), ], ], ), @@ -120,47 +118,79 @@ class RecommendTopNav extends StatelessWidget { ); } - Widget _tab({ - required Key key, - required String code, - required String label, - required bool selected, - required List? shadows, - required VoidCallback onTap, - }) { + Widget _tab( + (RecommendInnerTab, String, String) item, + List? shadows, + BuildContext context, + ) { + final (tab, label, code) = item; + final isSelected = selected == tab; + final position = pagePosition; + final progress = position == null + ? null + : (1 - (position - tab.index).abs()).clamp(0.0, 1.0); return GestureDetector( - key: key, + key: Key('recommend-tab-$code'), behavior: HitTestBehavior.opaque, - onTap: onTap, + onTap: () => onSelected(tab), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ // 补偿文字下方的 4pt 间距 + 3pt 下划线,让文字视觉中心与右侧图标对齐。 const SizedBox(height: 4 + _underlineHeight), - Text( - label, - maxLines: 1, + AnimatedNavLabel( + label: label, + selected: isSelected, + selectionProgress: progress, + inactiveColor: AppColors.homeTabInactive, style: TextStyle( color: AppColors.textPrimary, fontSize: _tabFont, - fontWeight: selected ? FontWeight.w700 : FontWeight.w400, + fontWeight: FontWeight.w400, letterSpacing: 0, height: 1.0, shadows: shadows, ), ), const SizedBox(height: 4), - Container( - key: Key('recommend-tab-$code-underline'), - width: _underlineWidth, - height: _underlineHeight, - decoration: BoxDecoration( - color: selected ? AppColors.brandRed : AppColors.transparent, - borderRadius: BorderRadius.circular(_underlineHeight / 2), - ), + _underline( + code, + progress, + isSelected, + MediaQuery.disableAnimationsOf(context), ), ], ), ); } + + 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( + 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), + ), + ); } diff --git a/test/core/animated_nav_label_test.dart b/test/core/animated_nav_label_test.dart new file mode 100644 index 0000000..f90baa6 --- /dev/null +++ b/test/core/animated_nav_label_test.dart @@ -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( + 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( + 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); + }); +} diff --git a/test/home/home_featured_label_test.dart b/test/home/home_featured_label_test.dart new file mode 100644 index 0000000..bcb997b --- /dev/null +++ b/test/home/home_featured_label_test.dart @@ -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: (_) {}, + ), + ), +); diff --git a/test/home/home_grid_layout_test.dart b/test/home/home_grid_layout_test.dart index 3ff30f3..d4f477c 100644 --- a/test/home/home_grid_layout_test.dart +++ b/test/home/home_grid_layout_test.dart @@ -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/theme/app_colors.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/shared_preferences_async_platform_interface.dart'; @@ -50,7 +52,7 @@ void main() { .toList(); expect(centers, orderedEquals(centers.toList()..sort())); 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('Charts'), findsNothing); expect(find.byKey(const Key('mine-mini-player')), findsNothing); @@ -173,7 +175,7 @@ void main() { matching: find.text('Playlists'), ), ); - expect(selected.style?.fontWeight, FontWeight.w700); + expect(selected.style?.fontWeight, FontWeight.w400); expect( tester.getRect(find.byKey(const Key('recommend-top-nav'))), topNavBefore, @@ -191,6 +193,46 @@ void main() { 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(find.byType(RecommendTopNav)); + expect(nav.pagePosition, inExclusiveRange(0.0, 0.5)); + final underline = tester.widget( + 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(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', ( tester, ) async { diff --git a/test/home/home_nav_ui_test.dart b/test/home/home_nav_ui_test.dart index f70b99f..66475e8 100644 --- a/test/home/home_nav_ui_test.dart +++ b/test/home/home_nav_ui_test.dart @@ -67,10 +67,7 @@ void main() { tester.getCenter(searchEntry).dy, closeTo(tester.getCenter(walletEntry).dy, 0.1), ); - expect( - tester.getTopRight(walletEntry).dx, - closeTo(390 - 8, 0.1), - ); + expect(tester.getTopRight(walletEntry).dx, closeTo(390 - 8, 0.1)); expect( tester.getTopRight(searchEntry).dx, closeTo(tester.getTopLeft(walletEntry).dx, 0.1), @@ -160,7 +157,7 @@ void main() { expect( find.descendant( of: find.byKey(const Key('tab-recommend')), - matching: find.byIcon(Icons.celebration_outlined), + matching: find.byIcon(Icons.smart_display_outlined), ), findsOneWidget, ); @@ -191,7 +188,7 @@ void main() { expect( find.descendant( of: find.byKey(const Key('tab-recommend')), - matching: find.byIcon(Icons.celebration_rounded), + matching: find.byIcon(Icons.smart_display_rounded), ), findsOneWidget, ); diff --git a/test/home/home_preview_layout_test.dart b/test/home/home_preview_layout_test.dart index 796d6ce..6e94a94 100644 --- a/test/home/home_preview_layout_test.dart +++ b/test/home/home_preview_layout_test.dart @@ -198,6 +198,11 @@ void main() { posterUrl: '', genre: 'Romance', category: 'Urban', + types: [ + {'en': 'Romance'}, + {'en': 'Urban'}, + {'en': 'Revenge'}, + ], totalEpisodes: 8, freeEpisodes: 8, viewCount: 419000, @@ -255,7 +260,7 @@ void main() { ); expect(seriesTitle.data, 'Feed Drama'); 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?.color, AppColors.textPrimary); final seriesChevron = tester.widget( @@ -284,11 +289,13 @@ void main() { 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-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-Revenge')), findsNothing); final collapsedSynopsis = tester.widget( find.byKey(const Key('home-feed-synopsis')), ); - expect(collapsedSynopsis.maxLines, 1); + expect(collapsedSynopsis.maxLines, 2); expect(collapsedSynopsis.overflow, TextOverflow.ellipsis); expect(find.byKey(const Key('home-feed-synopsis-more')), findsOneWidget); await tester.tap(find.byKey(const Key('home-feed-synopsis-more'))); @@ -302,8 +309,9 @@ void main() { .widget(find.byKey(const Key('home-feed-synopsis-expanded'))) .textSpan! .toPlainText(), - allOf(contains('EP2'), contains('dangerous city')), + contains('dangerous city'), ); + expect(find.text('Episode 2 / 8'), findsOneWidget); expect( tester.widget(find.byKey(const Key('rail-like-count'))).data, '1006', @@ -358,10 +366,10 @@ void main() { final likeRect = tester.getRect(find.byKey(const Key('rail-like'))); final commentsRect = tester.getRect(find.byKey(const Key('rail-comments'))); final shareRect = tester.getRect(find.byKey(const Key('rail-share'))); - // 新版互动栏顺序:收藏 → 评论 → 喜欢 → 分享,间距 12。 - expect(commentsRect.top - saveRect.bottom, closeTo(12, 0.1)); - expect(likeRect.top - commentsRect.bottom, closeTo(12, 0.1)); - expect(shareRect.top - likeRect.bottom, closeTo(12, 0.1)); + // 互动栏顺序:收藏 → 评论 → 喜欢 → 分享,紧凑间距 6。 + expect(commentsRect.top - saveRect.bottom, closeTo(6, 0.1)); + expect(likeRect.top - commentsRect.bottom, closeTo(6, 0.1)); + expect(shareRect.top - likeRect.bottom, closeTo(6, 0.1)); expect( tester.getRect(find.byKey(const Key('home-feed-action-rail'))).right, closeTo( @@ -582,9 +590,13 @@ void main() { final overlayRect = tester.getRect( find.byKey(const Key('home-feed-gesture')), ); - // 观看条与底部进度条共用同一组左右边距(各 20pt),宽度一致。 + // 观看条与左侧信息等宽,进度条仍横跨整个页面,避开右侧互动栏。 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( bar.left, closeTo( @@ -594,9 +606,8 @@ void main() { ); expect( bar.width, - closeTo( + lessThan( tester.getRect(find.byKey(const Key('home-feed-progress'))).width, - 0.1, ), ); // 进度线贴着浮层底部(线心距底 4pt),且位于观看条下方。 @@ -609,10 +620,10 @@ void main() { tester .widget(find.byKey(const Key('home-feed-full-series-bg'))) .color, - AppColors.textPrimary.withValues(alpha: 0.15), + Colors.transparent, ); expect( - tester.getRect(find.textContaining('Watch full series')).center.dy, + tester.getRect(find.text('Watch all')).center.dy, closeTo(bar.center.dy, 2), ); expect( diff --git a/test/home/top_navigation_motion_test.dart b/test/home/top_navigation_motion_test.dart new file mode 100644 index 0000000..e802f06 --- /dev/null +++ b/test/home/top_navigation_motion_test.dart @@ -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( + find.descendant(of: label, matching: find.byType(Transform)), + ) + .transform + .entry(0, 0); +} + +Future _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( + 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(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'); + }); +} diff --git a/test/recommend/home_poster_badge_weight_test.dart b/test/recommend/home_poster_badge_weight_test.dart new file mode 100644 index 0000000..6071a46 --- /dev/null +++ b/test/recommend/home_poster_badge_weight_test.dart @@ -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(label); + expect(text.style?.fontWeight, FontWeight.w400); + expect(text.style?.fontSize, 11); + expect(text.style?.color, AppColors.textPrimary); + final badge = tester.widget( + 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( + 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, + ), + ), + ), +); diff --git a/test/recommend/home_tab_motion_controller_test.dart b/test/recommend/home_tab_motion_controller_test.dart new file mode 100644 index 0000000..195943a --- /dev/null +++ b/test/recommend/home_tab_motion_controller_test.dart @@ -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 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 = []; + 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(); + }); +} diff --git a/test/recommend/recommend_sections_test.dart b/test/recommend/recommend_sections_test.dart index 00fb4e8..b9e84c7 100644 --- a/test/recommend/recommend_sections_test.dart +++ b/test/recommend/recommend_sections_test.dart @@ -44,11 +44,11 @@ void main() { final recommendText = tester.widget( find.descendant( of: find.byKey(const Key('recommend-tab-recommend')), - matching: find.text('Recommend'), + matching: find.text('Featured'), ), ); - expect(recommendText.style?.fontSize, 17); - expect(recommendText.style?.fontWeight, FontWeight.w700); + expect(recommendText.style?.fontSize, 18); + expect(recommendText.style?.fontWeight, FontWeight.w400); expect(recommendText.style?.color, AppColors.textPrimary); final playlistText = tester.widget( find.descendant( @@ -56,10 +56,17 @@ void main() { matching: find.text('Playlists'), ), ); - expect(playlistText.style?.fontSize, 17); + expect(playlistText.style?.fontSize, 18); + // 字形度量固定,未选中态靠绘制缩放和灰色区分,切换时不重新排版。 expect(playlistText.style?.fontWeight, FontWeight.w400); - // 新版顶部 tab 未选中态也用主文字色,选中态只靠字重和下划线区分。 - expect(playlistText.style?.color, AppColors.textPrimary); + expect(playlistText.style?.color, AppColors.homeTabInactive); + final playlistScale = tester.widget( + 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( tester .widget(find.byKey(const Key('recommend-search'))) diff --git a/test/recommend/recommend_top_nav_motion_test.dart b/test/recommend/recommend_top_nav_motion_test.dart new file mode 100644 index 0000000..8de6243 --- /dev/null +++ b/test/recommend/recommend_top_nav_motion_test.dart @@ -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( + 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( + 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( + 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( + 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( + 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)); + }); +}