diff --git a/README.md b/README.md index 62c0847..3200b7a 100644 --- a/README.md +++ b/README.md @@ -200,7 +200,7 @@ flutter test flutter build apk --debug ``` -当前基线:`dart analyze` 仅 1 条既存 info(`intl` 未在 `pubspec.yaml` 声明); +当前基线:`dart analyze` 仅 1 条既存 info(`intl` 未在 `pubspec.yaml` 声明); `flutter test` 全量 775 例通过。测试文件按模块放在 `test/` 下,用例名使用 `should_<期望>_when_<条件>`。真实后端联调测试在服务未启动时会跳过。 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/core/widgets/image_placeholder_shimmer.dart b/lib/core/widgets/image_placeholder_shimmer.dart new file mode 100644 index 0000000..7956e0f --- /dev/null +++ b/lib/core/widgets/image_placeholder_shimmer.dart @@ -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 createState() => + _ImagePlaceholderShimmerState(); +} + +class _ImagePlaceholderShimmerState extends State + 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 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; +} diff --git a/lib/core/widgets/remote_image.dart b/lib/core/widgets/remote_image.dart index c5bd75c..097883a 100644 --- a/lib/core/widgets/remote_image.dart +++ b/lib/core/widgets/remote_image.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import '../config/app_config.dart'; import '../theme/app_colors.dart'; +import 'image_placeholder_shimmer.dart'; const int _minImageDecodePixels = 96; const int _maxImageDecodePixels = 2048; @@ -102,13 +103,19 @@ class RemoteImage extends StatelessWidget { errorBuilder: (_, _, _) => _fallback(brand: showFallbackIcon), frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { 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}) { final custom = fallbackWidget; if (custom != null) return custom; 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/following/following_favorites_grid.dart b/lib/pages/following/following_favorites_grid.dart index 92a712d..d98cb26 100644 --- a/lib/pages/following/following_favorites_grid.dart +++ b/lib/pages/following/following_favorites_grid.dart @@ -1,22 +1,18 @@ // Author: fruit.com.cn +// 职责:追剧收藏网格、分页和编辑选择,不承载顶部续播或重复区块标题。 import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import '../../core/data/models/watch_record.dart'; import '../../core/data/services/favorite_controller.dart'; -import '../../core/data/services/watch_records_controller.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 '../../l10n/app_localizations.dart'; import '../player/open_player.dart'; import 'following_grid_card.dart'; import 'following_paged_grid.dart'; -import 'widgets/continue_watching_banner.dart'; /// 追剧编辑中的选中状态。 class FollowingFavoriteSelection { @@ -36,19 +32,15 @@ class FollowingFavoritesGrid extends StatelessWidget { const FollowingFavoritesGrid({ super.key, required this.controller, - required this.history, required this.selection, required this.scrollController, required this.onGoHome, - required this.onContinueRecord, }); final FavoriteController controller; - final WatchRecordsController history; final FollowingFavoriteSelection selection; final ScrollController scrollController; final VoidCallback onGoHome; - final ValueChanged onContinueRecord; @override Widget build(BuildContext context) { @@ -59,7 +51,7 @@ class FollowingFavoritesGrid extends StatelessWidget { valueListenable: selection.selected, builder: (context, picked, _) { return ListenableBuilder( - listenable: Listenable.merge([controller, history]), + listenable: controller, builder: (context, _) => _grid(context, isEditing, picked), ); }, @@ -89,7 +81,6 @@ class FollowingFavoritesGrid extends StatelessWidget { onRefresh: controller.load, onLoadMore: controller.loadMore, itemCount: controller.cards.length + extra, - topSlivers: _topSlivers(context), empty: EmptyState( key: const Key('following-favorites-empty'), icon: Icons.bookmark_border_rounded, @@ -99,68 +90,6 @@ class FollowingFavoritesGrid extends StatelessWidget { ); } - List _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( BuildContext context, int index, diff --git a/lib/pages/following/following_page.dart b/lib/pages/following/following_page.dart index d099fb9..e595d8b 100644 --- a/lib/pages/following/following_page.dart +++ b/lib/pages/following/following_page.dart @@ -1,4 +1,5 @@ // Author: fruit.com.cn +// 职责:追剧收藏与观看历史切换、数据刷新、管理和续播入口。 import 'dart:async'; @@ -39,7 +40,7 @@ class FollowingPage extends StatefulWidget { final FavoriteController favoriteController; final int historyRequest; - /// 当前是否为首页选中的 tab;重新可见时刷新观看历史,保证横幅是最新进度。 + /// 当前是否为首页选中的 tab;重新可见时刷新观看历史,保证记录是最新进度。 final bool visible; @override @@ -157,7 +158,6 @@ class _FollowingPageState extends State { FollowingKeepAliveTab( child: FollowingFavoritesGrid( controller: widget.favoriteController, - history: _history, selection: FollowingFavoriteSelection( editing: _editing, selected: _selected, @@ -165,9 +165,6 @@ class _FollowingPageState extends State { ), scrollController: _favoriteScroll, onGoHome: widget.onGoHome, - onContinueRecord: (record) => unawaited( - _openHistory(context, _history, record), - ), ), ), FollowingKeepAliveTab( 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/player/widgets/expandable_synopsis.dart b/lib/pages/player/widgets/expandable_synopsis.dart new file mode 100644 index 0000000..6936288 --- /dev/null +++ b/lib/pages/player/widgets/expandable_synopsis.dart @@ -0,0 +1,166 @@ +// Author: fruit.com.cn +// 职责:推荐流与播放页共用的简介样式、固定切换入口与高度过渡。 +import 'package:flutter/material.dart'; + +import '../../../core/theme/app_colors.dart'; +import '../../../l10n/app_localizations.dart'; + +const double _synopsisFont = 15; +const double _synopsisLineHeight = 1.4; +const int _collapsedSynopsisLines = 2; +const double _synopsisToggleWidthFraction = 0.55; +const double _synopsisToggleGap = 6; +const _synopsisMotion = Duration(milliseconds: 240); +const _synopsisStyle = TextStyle( + color: AppColors.overlaySecondary, + fontSize: _synopsisFont, + fontWeight: FontWeight.w400, + height: _synopsisLineHeight, +); +const _synopsisToggleStyle = TextStyle( + color: AppColors.textPrimary, + fontSize: _synopsisFont, + fontWeight: FontWeight.w400, + height: _synopsisLineHeight, +); + +/// 两行折叠的简介,展开与收起入口在右下共用同一占位。 +class ExpandableSynopsis extends StatefulWidget { + /// [keyPrefix] 区分页内标识,[expandedFooter] 仅在展开时展示。 + const ExpandableSynopsis({ + super.key, + required this.synopsis, + this.keyPrefix = 'home-feed', + this.expandedFooter, + }); + + /// 按当前语言获取的简介正文。 + final String synopsis; + + /// 当前页面的组件标识前缀。 + final String keyPrefix; + + /// 展开后附加的声明等内容。 + final Widget? expandedFooter; + + @override + State createState() => _ExpandableSynopsisState(); +} + +class _ExpandableSynopsisState extends State { + bool _expanded = false; + + @override + void didUpdateWidget(covariant ExpandableSynopsis oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.synopsis != widget.synopsis) _expanded = false; + } + + @override + Widget build(BuildContext context) { + if (widget.synopsis.trim().isEmpty) return const SizedBox.shrink(); + final l10n = AppLocalizations.of(context)!; + return LayoutBuilder( + builder: (context, constraints) { + if (!_needsToggle(constraints.maxWidth)) return _body(); + return Row( + key: _key('layout'), + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _toggle, + child: _animatedBody(), + ), + ), + const SizedBox(width: _synopsisToggleGap), + ConstrainedBox( + constraints: BoxConstraints( + maxWidth: constraints.maxWidth * _synopsisToggleWidthFraction, + ), + child: IntrinsicWidth(child: _toggleButton(l10n)), + ), + ], + ); + }, + ); + } + + bool _needsToggle(double maxWidth) { + final painter = _painter(widget.synopsis, _synopsisStyle); + painter.layout(maxWidth: maxWidth); + final overflows = painter.didExceedMaxLines; + painter.dispose(); + return overflows; + } + + TextPainter _painter(String text, TextStyle style) => TextPainter( + text: TextSpan( + text: text, + style: DefaultTextStyle.of(context).style.merge(style), + ), + maxLines: _collapsedSynopsisLines, + textDirection: Directionality.of(context), + textScaler: MediaQuery.textScalerOf(context), + locale: Localizations.localeOf(context), + textHeightBehavior: DefaultTextStyle.of(context).textHeightBehavior, + ); + + Widget _animatedBody() { + if (MediaQuery.disableAnimationsOf(context)) return _body(); + return AnimatedSize( + key: _key('size'), + duration: _synopsisMotion, + curve: Curves.easeInOutCubic, + alignment: Alignment.topLeft, + child: _body(), + ); + } + + Key _key(String suffix) => Key('${widget.keyPrefix}-synopsis-$suffix'); + + Widget _body() { + final text = Text.rich( + TextSpan(text: widget.synopsis, style: _synopsisStyle), + key: _expanded ? _key('expanded') : Key('${widget.keyPrefix}-synopsis'), + style: _synopsisStyle, + maxLines: _expanded ? null : _collapsedSynopsisLines, + overflow: _expanded ? TextOverflow.clip : TextOverflow.ellipsis, + ); + if (!_expanded || widget.expandedFooter == null) return text; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [text, widget.expandedFooter!], + ); + } + + Widget _toggleButton(AppLocalizations l10n) => Semantics( + button: true, + expanded: _expanded, + child: GestureDetector( + key: _key('more'), + behavior: HitTestBehavior.opaque, + onTap: _toggle, + child: IndexedStack( + index: _expanded ? 1 : 0, + alignment: Alignment.bottomRight, + children: [ + _toggleLabel(l10n.expandSynopsis), + _toggleLabel(l10n.showLess), + ], + ), + ), + ); + + Widget _toggleLabel(String label) => Text( + label, + maxLines: _collapsedSynopsisLines, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.right, + style: _synopsisToggleStyle, + ); + + void _toggle() => setState(() => _expanded = !_expanded); +} diff --git a/lib/pages/player/widgets/home_feed_info_tags.dart b/lib/pages/player/widgets/home_feed_info_tags.dart new file mode 100644 index 0000000..36cffb1 --- /dev/null +++ b/lib/pages/player/widgets/home_feed_info_tags.dart @@ -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 chips; + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + key: const Key('home-feed-tags'), + scrollDirection: Axis.horizontal, + physics: const BouncingScrollPhysics(), + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + for (var index = 0; index < chips.length; index++) ...[ + if (index > 0) const SizedBox(width: _tagGap), + chips[index], + ], + ], + ), + ); + } +} + +/// 热搜入口保留点击搜索,长语言文案省略而不挤占信息区。 +class _HotSearchPill extends StatelessWidget { + const _HotSearchPill({required this.label, required this.onTap}); + + final String label; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) => GestureDetector( + key: const Key('home-feed-hot-search-pill'), + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: _searchInset, + vertical: _searchVertical, + ), + decoration: BoxDecoration( + color: AppColors.textPrimary.withValues(alpha: _searchFillAlpha), + borderRadius: BorderRadius.circular(_searchRadius), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.search_rounded, + size: _searchIcon, + color: AppColors.textSecondary, + ), + const SizedBox(width: _searchIconGap), + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: _tagFont, + fontWeight: FontWeight.w500, + ), + ), + ), + const SizedBox(width: _searchIconGap), + const Icon( + Icons.chevron_right_rounded, + size: _searchIcon, + color: AppColors.textSecondary, + ), + ], + ), + ), + ); +} + +class _TagChip extends StatelessWidget { + const _TagChip({super.key, required this.label}); + + final String label; + + @override + Widget build(BuildContext context) => Text( + label, + maxLines: 1, + strutStyle: _metadataStrutStyle, + style: const TextStyle( + color: AppColors.overlaySecondary, + fontSize: _tagFont, + fontWeight: FontWeight.w500, + height: _metadataLineHeight, + ), + ); +} diff --git a/lib/pages/player/widgets/home_feed_overlay.dart b/lib/pages/player/widgets/home_feed_overlay.dart index 40fecd0..4331df2 100644 --- a/lib/pages/player/widgets/home_feed_overlay.dart +++ b/lib/pages/player/widgets/home_feed_overlay.dart @@ -1,4 +1,5 @@ // Author: fruit.com.cn +// 职责:推荐视频浮层的底部信息、互动栏、进度条与手势协调。 import 'dart:async'; @@ -27,10 +28,13 @@ const double _progressHitHeight = 16; const double _pageHorizontal = 20; const double _railRight = 14; const double _railGap = 12; +const double _railWidth = 56; +const double _infoWatchAllGap = 2; // 进度线贴着屏幕底部(线心距底 4pt),观看条底边只留 8pt,视觉上就是线上一根条。 const double _watchAllBottomInset = 8; -// 信息面板底边直接贴住观看条顶沿,中间不留缝。 -const double _bottomInfoInset = _watchAllBottomInset + homeFeedWatchAllHeight; +// 简介与观看条留出明确间距,展开后仍保留相同的底部留白。 +const double _bottomInfoInset = + _watchAllBottomInset + homeFeedWatchAllHeight + _infoWatchAllGap; const double _railBottomOffset = 40; const Duration _overlayFadeDuration = Duration(milliseconds: 120); const List _boostTextShadows = [ @@ -169,7 +173,7 @@ class _HomeFeedOverlayState extends State { // 否则拖拽 seek 的起点会被观看条吞掉。两块的可见区域(文字/进度线)互不遮挡。 Positioned( left: _pageHorizontal, - right: _pageHorizontal, + right: _railRight + _railGap + _railWidth, bottom: _watchAllBottomInset, child: AnimatedOpacity( duration: _overlayFadeDuration, @@ -224,9 +228,14 @@ class _HomeFeedOverlayState extends State { children: [ Expanded(child: _bottomInfo()), const SizedBox(width: _railGap), - Padding( - padding: const EdgeInsets.only(bottom: _railBottomOffset), - child: _rail(), + SizedBox( + width: _railWidth, + child: Padding( + padding: const EdgeInsets.only( + bottom: _railBottomOffset, + ), + child: _rail(), + ), ), ], ), diff --git a/lib/pages/player/widgets/home_feed_recommend_panel.dart b/lib/pages/player/widgets/home_feed_recommend_panel.dart index c42ffa0..9fdd14c 100644 --- a/lib/pages/player/widgets/home_feed_recommend_panel.dart +++ b/lib/pages/player/widgets/home_feed_recommend_panel.dart @@ -1,32 +1,47 @@ // Author: fruit.com.cn +// 职责:推荐视频底部标题、标签、集数、简介与观看全集入口。 import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import '../../../core/data/models/drama.dart'; +import '../../../core/data/models/locale_text.dart'; import '../../../core/formatting/localized_content.dart'; import '../../../core/theme/app_colors.dart'; import '../../../l10n/app_localizations.dart'; -import '../../home/widgets/home_drama_classification.dart'; +import 'expandable_synopsis.dart'; import 'interaction_rail.dart'; -const double _seriesFont = 17; -const double _tagFont = 12; -const double _synopsisFont = 14; +part 'home_feed_info_tags.dart'; + +const double _seriesFont = 18; +const double _tagFont = 13; const double _watchAllHeight = 38; +const double _titleMetadataGap = 2; +const double _metadataSynopsisGap = 2; +const double _hotSearchBottomGap = 2; +const double _metadataGap = 8; +const int _maximumTags = 2; +const double _episodeFont = 14; +const double _episodeWidthFraction = 0.55; +const double _metadataLineHeight = 1.4; +const _metadataStrutStyle = StrutStyle( + fontSize: _episodeFont, + height: _metadataLineHeight, + forceStrutHeight: true, +); /// 观看条内部左右留白与圆角:外沿由 overlay 按页面边距对齐,内部不再重复内缩。 const double _watchAllInset = 12; -const double _watchAllRadius = 12; +const double _watchAllRadius = 6; +const double _watchAllFont = 16; +const _watchAllGradient = LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [AppColors.mineStatShade, AppColors.brandRed, AppColors.vipButtonEnd], +); -/// 标题、标签、简介之间的行间距:底部信息本来就挤,留 4pt 够分层。 -const double _blockGap = 4; -const List _textShadows = [ - Shadow(color: AppColors.shadow, blurRadius: 6, offset: Offset(0, 1)), -]; -final RegExp _tagSplitter = RegExp(r'[,/、·]'); - -/// 通栏「观看完整剧集」半透明白底条高度,overlay 用它安排底部避让。 +/// 「观看完整剧集」渐变按钮高度,overlay 用它安排底部避让。 const double homeFeedWatchAllHeight = _watchAllHeight; /// 播放量紧凑展示(跟随语言):zh 20万 / en 200K / id 200 rb。 @@ -56,15 +71,13 @@ class HomeFeedInfoPanel extends StatefulWidget { } class _HomeFeedInfoPanelState extends State { - bool _synopsisExpanded = false; - @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; final dramaTitle = localizedDramaTitle(l10n, widget.drama); final synopsis = localizedDramaSynopsis(l10n, widget.drama).trim(); - final genres = _genreValues(Localizations.localeOf(context).languageCode); - final tags = _tagChips(l10n, genres); + final types = _typeLabels(Localizations.localeOf(context).languageCode); + final tags = _tagChips(types); return Column( key: const Key('home-feed-info-panel'), mainAxisSize: MainAxisSize.min, @@ -80,306 +93,108 @@ class _HomeFeedInfoPanelState extends State { ), onTap: widget.onOpenSearch!, ), - const SizedBox(height: 8), - ], - GestureDetector( - key: const Key('home-feed-series-name'), - behavior: HitTestBehavior.opaque, - onTap: widget.onOpenFullPlayer, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Flexible( - child: Text( - dramaTitle, - key: const Key('home-feed-series-title'), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: _seriesFont, - fontWeight: FontWeight.w400, - shadows: _textShadows, - ), - ), - ), - const SizedBox(width: 2), - const Icon( - Icons.chevron_right_rounded, - size: 18, - color: AppColors.textPrimary, - ), - ], - ), - ), - // 没有标签就整行跳过,不给标题和简介之间留空档。 - if (tags.isNotEmpty) ...[ - const SizedBox(height: _blockGap), - _tagRow(tags), + const SizedBox(height: _hotSearchBottomGap), ], + _title(dramaTitle), + const SizedBox(height: _titleMetadataGap), + _metadata(l10n, tags), if (synopsis.isNotEmpty) ...[ - const SizedBox(height: _blockGap), - _synopsis(l10n, synopsis), + const SizedBox(height: _metadataSynopsisGap), + ExpandableSynopsis( + key: ValueKey(widget.drama.id), + synopsis: synopsis, + ), ], ], ); } - List _tagChips(AppLocalizations l10n, List genres) { - return [ - if (isHotDrama(widget.drama)) - _TagChip( - key: const Key('home-feed-hot-badge'), - label: l10n.homeBadgeHot, - filled: true, + Widget _title(String dramaTitle) => GestureDetector( + key: const Key('home-feed-series-name'), + behavior: HitTestBehavior.opaque, + onTap: widget.onOpenFullPlayer, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + dramaTitle, + key: const Key('home-feed-series-title'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: _seriesFont, + fontWeight: FontWeight.w400, + ), + ), ), - for (final genre in genres) - _TagChip(key: Key('home-feed-tag-$genre'), label: genre), + const SizedBox(width: 2), + const Icon( + Icons.chevron_right_rounded, + size: 18, + color: AppColors.textPrimary, + ), + ], + ), + ); + + List _tagChips(List types) { + return [ + for (final type in types) + _TagChip(key: Key('home-feed-tag-$type'), label: type), ]; } - Widget _tagRow(List chips) { - return SizedBox( - key: const Key('home-feed-tags'), - height: 22, - child: ListView.separated( - scrollDirection: Axis.horizontal, - physics: const BouncingScrollPhysics(), - shrinkWrap: true, - itemCount: chips.length, - separatorBuilder: (_, _) => const SizedBox(width: 6), - itemBuilder: (_, index) => chips[index], - ), - ); - } - - Widget _synopsis(AppLocalizations l10n, String synopsis) { - final prefix = l10n.feedEpisodePrefix(widget.episode.number); - final baseStyle = const TextStyle( - color: AppColors.textPrimary, - fontSize: _synopsisFont, - height: 1.3, - shadows: _textShadows, - ); - final span = TextSpan( - children: [ - TextSpan(text: prefix, style: baseStyle), - TextSpan( - text: ' | ', - style: baseStyle.copyWith( - color: AppColors.textPrimary.withValues(alpha: 0.45), - fontSize: _synopsisFont - 2, - fontWeight: FontWeight.w400, - ), - ), - TextSpan(text: synopsis, style: baseStyle), - ], - ); - if (_synopsisExpanded) { - return _expandedSynopsis(span, baseStyle, l10n); - } + Widget _metadata(AppLocalizations l10n, List tags) { return LayoutBuilder( builder: (context, constraints) { - final toggle = _synopsisToggle( - key: const Key('home-feed-synopsis-more'), - label: l10n.expandSynopsis, - padding: const EdgeInsets.only(left: 6), - ); - final painter = TextPainter( - text: span, + final episode = Text( + widget.drama.episodeCount > 0 + ? l10n.episodeOfTotal( + widget.episode.number, + widget.drama.episodeCount, + ) + : l10n.feedEpisodePrefix(widget.episode.number), + key: const Key('home-feed-episode-meta'), maxLines: 1, - textDirection: Directionality.of(context), - textScaler: MediaQuery.textScalerOf(context), - )..layout(maxWidth: constraints.maxWidth); - if (!painter.didExceedMaxLines) { - return Text.rich( - span, - key: const Key('home-feed-synopsis'), - maxLines: 1, - style: baseStyle, - ); - } - // 设计稿:单行省略号 + 行内固定「展开」。 - // 整行可点:真机上不必精确命中两个小字,点简介任意位置都能展开。 - return GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => setState(() => _synopsisExpanded = true), - child: Row( - key: const Key('home-feed-synopsis-collapsed'), - // 顶对齐:文字位置不变,toggle 的 bottom 留白把触控区向下扩到空白处。 - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Text.rich( - span, - key: const Key('home-feed-synopsis'), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: baseStyle, - ), - ), - const SizedBox(width: 2), - toggle, - ], + overflow: TextOverflow.ellipsis, + strutStyle: _metadataStrutStyle, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: _episodeFont, + height: _metadataLineHeight, ), ); + return Row( + key: const Key('home-feed-metadata'), + children: [ + if (tags.isNotEmpty) ...[ + Flexible(child: _TagStrip(chips: tags)), + const SizedBox(width: _metadataGap), + ], + ConstrainedBox( + constraints: BoxConstraints( + maxWidth: tags.isEmpty + ? constraints.maxWidth + : constraints.maxWidth * _episodeWidthFraction, + ), + child: episode, + ), + ], + ); }, ); } - Widget _expandedSynopsis( - InlineSpan span, - TextStyle style, - AppLocalizations l10n, - ) { - // 「收起」内联在简介最后一行末尾,避免独占一行导致多行简介与进度条间 - // 出现大片空白;垂直方向只留少量触控留白(仅撑高最后一行)。 - final expandedSpan = TextSpan( - style: style, - children: [ - span, - WidgetSpan( - alignment: PlaceholderAlignment.baseline, - baseline: TextBaseline.alphabetic, - child: _synopsisToggle( - key: const Key('home-feed-synopsis-more'), - label: l10n.showLess, - padding: const EdgeInsets.only(left: 8), - ), - ), - ], - ); - return Text.rich( - expandedSpan, - key: const Key('home-feed-synopsis-expanded'), - ); - } - - Widget _synopsisToggle({ - required Key key, - required String label, - required EdgeInsets padding, - }) { - return GestureDetector( - key: key, - // opaque + 留白扩大命中区;留白方向由调用方决定, - // 展开态不能加 top padding,否则「收起」距简介正文过远。 - behavior: HitTestBehavior.opaque, - onTap: () => setState(() => _synopsisExpanded = !_synopsisExpanded), - child: Padding( - padding: padding, - child: Text( - label, - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: _synopsisFont, - fontWeight: FontWeight.w600, - shadows: _textShadows, - ), - ), - ), - ); - } - - List _genreValues(String languageCode) { - final localized = widget.drama.localizedTypeLabels(languageCode); - if (localized.isNotEmpty) return localized.take(3).toList(growable: false); - final result = []; - for (final value in [widget.drama.genre, widget.drama.category]) { - for (final part in value.split(_tagSplitter)) { - final tag = part.trim(); - if (tag.isEmpty || result.contains(tag)) continue; - result.add(tag); - } - } - return result.take(3).toList(growable: false); - } + List _typeLabels(String languageCode) => widget.drama.types + .take(_maximumTags) + .map((type) => localizedText(languageCode, '', type).trim()) + .where((label) => label.isNotEmpty) + .toList(growable: false); } -/// 热搜榜降级胶囊:🔍 共 xxx 人在看 ›(暂无榜单接口,点击跳搜索页)。 -class _HotSearchPill extends StatelessWidget { - const _HotSearchPill({required this.label, required this.onTap}); - - final String label; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - return GestureDetector( - key: const Key('home-feed-hot-search-pill'), - behavior: HitTestBehavior.opaque, - onTap: onTap, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), - decoration: BoxDecoration( - color: AppColors.textPrimary.withValues(alpha: 0.14), - borderRadius: BorderRadius.circular(6), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.search_rounded, - size: 15, - color: AppColors.textPrimary, - ), - const SizedBox(width: 5), - Text( - label, - maxLines: 1, - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: 14, - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(width: 2), - const Icon( - Icons.chevron_right_rounded, - size: 16, - color: AppColors.textPrimary, - ), - ], - ), - ), - ); - } -} - -/// 热剧红胶囊 / 类型深色半透明填充胶囊。 -class _TagChip extends StatelessWidget { - const _TagChip({super.key, required this.label, this.filled = false}); - - final String label; - final bool filled; - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - alignment: Alignment.center, - decoration: BoxDecoration( - color: filled - ? AppColors.brandRed - : AppColors.textPrimary.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(4), - ), - child: Text( - label, - maxLines: 1, - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: _tagFont, - fontWeight: FontWeight.w500, - height: 1.2, - shadows: _textShadows, - ), - ), - ); - } -} - -/// 底部「观看完整剧集」条:浮在底部,不再铺满整屏宽度。 +/// 左侧「观看全集」条:与信息区等宽,居中文字避免与右侧互动栏争抢。 class HomeFeedWatchAllBar extends StatelessWidget { const HomeFeedWatchAllBar({ super.key, @@ -395,49 +210,43 @@ class HomeFeedWatchAllBar extends StatelessWidget { final l10n = AppLocalizations.of(context)!; return ClipRRect( borderRadius: BorderRadius.circular(_watchAllRadius), - child: Material( - key: const Key('home-feed-full-series-bg'), - color: AppColors.textPrimary.withValues(alpha: 0.15), - child: InkWell( - key: const Key('home-feed-full-series'), - onTap: onTap, - child: SizedBox( - height: _watchAllHeight, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: _watchAllInset), - child: Row( - children: [ - const Icon( - Icons.movie_rounded, - size: 22, - color: AppColors.onPrimary, - ), - const SizedBox(width: 10), - Expanded( - child: Text( - l10n.watchFullSeries('${drama.episodeCount}'), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: 15, - fontWeight: FontWeight.w500, - ), - ), - ), - const Icon( - Icons.chevron_right_rounded, - size: 22, - color: AppColors.textPrimary, - ), - ], - ), - ), + child: DecoratedBox( + key: const Key('home-feed-full-series-gradient'), + decoration: const BoxDecoration(gradient: _watchAllGradient), + child: Material( + key: const Key('home-feed-full-series-bg'), + color: AppColors.transparent, + child: InkWell( + key: const Key('home-feed-full-series'), + onTap: onTap, + child: _content(l10n), ), ), ), ); } + + Widget _content(AppLocalizations l10n) => SizedBox( + width: double.infinity, + height: _watchAllHeight, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: _watchAllInset), + child: Center( + child: Text( + l10n.homeWatchAll, + semanticsLabel: l10n.watchFullSeries('${drama.episodeCount}'), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: _watchAllFont, + fontWeight: FontWeight.w400, + ), + ), + ), + ), + ); } class HomeFeedActionRail extends StatelessWidget { @@ -468,7 +277,7 @@ class HomeFeedActionRail extends StatelessWidget { Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; return InteractionRail( - itemGap: 12, + itemGap: interactionRailCompactGap, buttonSize: 48, iconSize: 36, countFontSize: 15, @@ -481,6 +290,7 @@ class HomeFeedActionRail extends StatelessWidget { count: favoriteCount, countKey: const Key('rail-save-count'), selected: saved, + animateTap: true, selectedColor: AppColors.brandRed, onTap: onSave, ), @@ -499,6 +309,7 @@ class HomeFeedActionRail extends StatelessWidget { count: likeCount, countKey: const Key('rail-like-count'), selected: liked, + animateTap: true, selectedColor: AppColors.brandRed, onTap: onLike, ), diff --git a/lib/pages/player/widgets/interaction_rail.dart b/lib/pages/player/widgets/interaction_rail.dart index 1b3f3ba..c3e9fcd 100644 --- a/lib/pages/player/widgets/interaction_rail.dart +++ b/lib/pages/player/widgets/interaction_rail.dart @@ -1,15 +1,44 @@ // Author: fruit.com.cn +// 职责:播放互动栏的图标、计数与不影响触控区域的点击反馈。 import 'package:flutter/material.dart'; import '../../../core/formatting/count_format.dart'; import '../../../core/theme/app_colors.dart'; +import 'interaction_rail_burst.dart'; const double _defaultButtonSize = 44; const double _defaultIconSize = 32; const double _defaultCountFont = 12; const double _defaultIconCountGap = 2; const double _defaultItemGap = 24; +const _tapDuration = Duration(milliseconds: 360); +const double _popScale = 1.34; +const double _cancelScale = 0.68; +const double _popWeight = 40; +const double _settleWeight = 60; +final _growTween = _tapTween(_popScale); +final _shrinkTween = _tapTween(_cancelScale); + +TweenSequence _tapTween(double target) => TweenSequence([ + TweenSequenceItem( + tween: Tween( + begin: 1.0, + end: target, + ).chain(CurveTween(curve: Curves.easeOutCubic)), + weight: _popWeight, + ), + TweenSequenceItem( + tween: Tween( + begin: target, + end: 1.0, + ).chain(CurveTween(curve: Curves.easeOutCubic)), + weight: _settleWeight, + ), +]); + +/// 首页与完整播放器互动项之间的紧凑间距。 +const double interactionRailCompactGap = 6; /// 互动栏单项:图标、计数与点击。 class InteractionRailItem { @@ -24,6 +53,7 @@ class InteractionRailItem { this.selected = false, this.selectedColor = AppColors.blue, this.mirrorIcon = false, + this.animateTap = false, }) : assert((asset == null) != (icon == null)); final Key key; @@ -38,6 +68,9 @@ class InteractionRailItem { /// 水平镜像图标(分享弯箭头用 reply 镜像得到)。 final bool mirrorIcon; + + /// 选中放大、取消缩小并显示周围纹路,计数和触控范围不参与缩放。 + final bool animateTap; } /// 播放页与首页 feed 共用的右侧互动栏:收藏/评论/喜欢/分享。 @@ -79,7 +112,7 @@ class InteractionRail extends StatelessWidget { } } -class _RailButton extends StatelessWidget { +class _RailButton extends StatefulWidget { const _RailButton({ required this.item, required this.buttonSize, @@ -94,50 +127,135 @@ class _RailButton extends StatelessWidget { final double countFontSize; final double iconCountGap; + @override + State<_RailButton> createState() => _RailButtonState(); +} + +class _RailButtonState extends State<_RailButton> + with SingleTickerProviderStateMixin { + late final AnimationController _motion; + Color _burstColor = AppColors.onPrimary; + bool _contracting = false; + + @override + void initState() { + super.initState(); + _motion = AnimationController(vsync: this, duration: _tapDuration); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (MediaQuery.disableAnimationsOf(context)) _motion.reset(); + } + + @override + void dispose() { + _motion.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { - final tint = item.selected ? item.selectedColor : AppColors.onPrimary; return Column( - key: item.key, + key: widget.item.key, mainAxisSize: MainAxisSize.min, children: [ SizedBox.square( - dimension: buttonSize, - child: IconButton( - onPressed: item.onTap, - padding: EdgeInsets.zero, - icon: item.asset != null - ? Image.asset( - item.asset!, - width: iconSize, - height: iconSize, - fit: BoxFit.contain, - color: tint, - colorBlendMode: BlendMode.srcIn, - ) - : Transform.flip( - flipX: item.mirrorIcon, - child: Icon(item.icon, size: iconSize, color: tint), - ), - ), - ), - SizedBox(height: iconCountGap), - Text( - item.count == null ? item.label : formatInteractionCount(item.count!), - key: item.countKey, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: AppColors.onPrimary, - height: 1, - fontSize: countFontSize, - fontWeight: FontWeight.w600, - shadows: const [ - Shadow(blurRadius: 6, color: AppColors.mediaBlack), - ], - ), + dimension: widget.buttonSize, + child: widget.item.animateTap ? _animatedButton() : _button(_icon()), ), + SizedBox(height: widget.iconCountGap), + _count(), ], ); } + + void _onTap() { + if (widget.item.animateTap && !MediaQuery.disableAnimationsOf(context)) { + _contracting = widget.item.selected; + _burstColor = _contracting + ? AppColors.onPrimary + : widget.item.selectedColor; + _motion.forward(from: 0); + } + widget.item.onTap(); + } + + Widget _button(Widget icon) => IconButton( + onPressed: _onTap, + iconSize: widget.iconSize, + padding: EdgeInsets.zero, + style: IconButton.styleFrom( + overlayColor: AppColors.transparent, + shadowColor: AppColors.transparent, + elevation: 0, + splashFactory: NoSplash.splashFactory, + ), + icon: icon, + ); + + Widget _animatedButton() => AnimatedBuilder( + animation: _motion, + child: _icon(), + builder: (context, child) => Stack( + fit: StackFit.expand, + alignment: Alignment.center, + clipBehavior: Clip.none, + children: [ + _button( + Transform.scale( + scale: (_contracting ? _shrinkTween : _growTween).transform( + _motion.value, + ), + child: child, + ), + ), + if (_motion.isAnimating) + Positioned.fill( + child: RailTapBurst( + key: const Key('rail-tap-burst'), + progress: _motion.value, + color: _burstColor, + contracting: _contracting, + ), + ), + ], + ), + ); + + Widget _icon() { + final item = widget.item; + final tint = item.selected ? item.selectedColor : AppColors.onPrimary; + return item.asset != null + ? Image.asset( + item.asset!, + width: widget.iconSize, + height: widget.iconSize, + fit: BoxFit.contain, + color: tint, + colorBlendMode: BlendMode.srcIn, + ) + : Transform.flip( + flipX: item.mirrorIcon, + child: Icon(item.icon, size: widget.iconSize, color: tint), + ); + } + + Widget _count() { + final item = widget.item; + return Text( + item.count == null ? item.label : formatInteractionCount(item.count!), + key: item.countKey, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: AppColors.onPrimary, + height: 1, + fontSize: widget.countFontSize, + fontWeight: FontWeight.w600, + shadows: const [Shadow(blurRadius: 6, color: AppColors.mediaBlack)], + ), + ); + } } diff --git a/lib/pages/player/widgets/interaction_rail_burst.dart b/lib/pages/player/widgets/interaction_rail_burst.dart new file mode 100644 index 0000000..0f28a72 --- /dev/null +++ b/lib/pages/player/widgets/interaction_rail_burst.dart @@ -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; +} diff --git a/lib/pages/player/widgets/player_overlay.dart b/lib/pages/player/widgets/player_overlay.dart index 55ea07c..3f23148 100644 --- a/lib/pages/player/widgets/player_overlay.dart +++ b/lib/pages/player/widgets/player_overlay.dart @@ -11,6 +11,7 @@ import '../../../core/formatting/localized_content.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; import '../../../l10n/app_localizations.dart'; +import 'expandable_synopsis.dart'; import 'player_center_toggle.dart'; import 'interaction_rail.dart'; import 'player_overlay_models.dart'; @@ -178,7 +179,7 @@ class PlayerPlaybackOverlay extends StatelessWidget { opacity: scrubbing ? 0 : 1, child: InteractionRail( key: const Key('player-interaction-rail'), - itemGap: 12, + itemGap: interactionRailCompactGap, buttonSize: 48, iconSize: 36, countFontSize: 15, @@ -190,6 +191,7 @@ class PlayerPlaybackOverlay extends StatelessWidget { count: data.favoriteCount, countKey: const Key('player-rail-save-count'), selected: data.saved, + animateTap: true, onTap: actions.onSave, ), InteractionRailItem( @@ -199,6 +201,7 @@ class PlayerPlaybackOverlay extends StatelessWidget { count: data.likeCount, countKey: const Key('player-rail-like-count'), selected: data.liked, + animateTap: true, selectedColor: AppColors.brandRed, onTap: actions.onLike, ), @@ -300,7 +303,6 @@ class PlayerPlaybackOverlay extends StatelessWidget { key: const Key('player-episode-synopsis'), l10n: data.l10n, drama: data.drama, - episodeNumber: data.episode.number, synopsis: synopsis, authorNote: data.authorNote, ), @@ -412,7 +414,7 @@ class PlayerPlaybackOverlay extends StatelessWidget { style: const TextStyle( color: AppColors.onPrimary, fontSize: 13.5, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w400, shadows: _playerTextShadows, ), ), diff --git a/lib/pages/player/widgets/player_overlay_parts.dart b/lib/pages/player/widgets/player_overlay_parts.dart index 2e01950..f06ffd7 100644 --- a/lib/pages/player/widgets/player_overlay_parts.dart +++ b/lib/pages/player/widgets/player_overlay_parts.dart @@ -118,14 +118,12 @@ class _EpisodeSynopsis extends StatefulWidget { super.key, required this.l10n, required this.drama, - required this.episodeNumber, required this.synopsis, required this.authorNote, }); final AppLocalizations l10n; final Drama drama; - final int episodeNumber; final String synopsis; final String authorNote; @@ -134,26 +132,13 @@ class _EpisodeSynopsis extends StatefulWidget { } class _EpisodeSynopsisState extends State<_EpisodeSynopsis> { - bool _expanded = false; String? _fullSynopsis; int _detailRequest = 0; bool _detailRequested = false; - /// 「第 n 集」、分隔符与简介正文共用同一套白字规格。 - static const TextStyle _lineStyle = TextStyle( - color: AppColors.onPrimary, - fontSize: 12, - fontWeight: FontWeight.w500, - height: 1.35, - shadows: _playerTextShadows, - ); - static const TextStyle _linkStyle = TextStyle( - color: AppColors.textPrimary, - fontSize: 12, - fontWeight: FontWeight.w600, - shadows: _playerTextShadows, - ); - static const int _collapsedMaxLines = 2; + static const int _authorNoteLines = 2; + static const double _authorNoteGap = 4; + static const double _authorNoteFont = 10; String get _synopsis => _fullSynopsis ?? widget.synopsis; @@ -173,7 +158,6 @@ class _EpisodeSynopsisState extends State<_EpisodeSynopsis> { return; } _fullSynopsis = null; - _expanded = false; _detailRequested = !widget.drama.detailLoaded; if (_detailRequested) unawaited(_loadFullSynopsis()); } @@ -200,123 +184,26 @@ class _EpisodeSynopsisState extends State<_EpisodeSynopsis> { @override Widget build(BuildContext context) { - return LayoutBuilder( - builder: (context, constraints) => _block(constraints.maxWidth), + return ExpandableSynopsis( + key: ValueKey(widget.drama.id), + keyPrefix: 'player', + synopsis: _synopsis, + expandedFooter: widget.authorNote.isEmpty ? null : _authorNote(), ); } - /// 与首页推荐 feed 的简介展开/收起保持一致: - /// 折叠单行省略 + 行尾「展开」(整行可点);展开后内联「收起」。 - Widget _block(double maxWidth) { - final label = widget.l10n.episodeLabel(widget.episodeNumber); - final span = TextSpan( - style: _lineStyle, - children: [ - TextSpan(text: label, style: _lineStyle), - _separatorSpan(), - TextSpan(text: _synopsis, style: _lineStyle), - ], - ); - if (_expanded) return _expandedBlock(span); - - final painter = TextPainter( - text: span, - maxLines: 1, - textDirection: Directionality.of(context), - textScaler: MediaQuery.textScalerOf(context), - )..layout(maxWidth: maxWidth); - if (!painter.didExceedMaxLines) { - return Text.rich( - span, - key: const Key('player-synopsis'), - maxLines: 1, - style: _lineStyle, - ); - } - return GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => setState(() => _expanded = true), - child: Row( - key: const Key('player-synopsis-collapsed'), - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Text.rich( - span, - key: const Key('player-synopsis'), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: _lineStyle, - ), - ), - const SizedBox(width: 2), - _toggle( - widget.l10n.expandSynopsis, - padding: const EdgeInsets.only(left: 6), - ), - ], + Widget _authorNote() => Padding( + padding: const EdgeInsets.only(top: _authorNoteGap), + child: Text( + widget.authorNote, + key: const Key('player-author-note'), + maxLines: _authorNoteLines, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.playerMuted, + fontSize: _authorNoteFont, + shadows: _playerTextShadows, ), - ); - } - - Widget _expandedBlock(InlineSpan span) { - final expandedSpan = TextSpan( - style: _lineStyle, - children: [ - span, - WidgetSpan( - alignment: PlaceholderAlignment.baseline, - baseline: TextBaseline.alphabetic, - child: _toggle( - widget.l10n.showLess, - padding: const EdgeInsets.only(left: 8), - ), - ), - ], - ); - return Column( - key: const Key('player-synopsis-expanded'), - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text.rich(expandedSpan, key: const Key('player-synopsis')), - if (widget.authorNote.isNotEmpty) ...[ - const SizedBox(height: 4), - Text( - widget.authorNote, - key: const Key('player-author-note'), - maxLines: _collapsedMaxLines, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.playerMuted, - fontSize: 10, - shadows: _playerTextShadows, - ), - ), - ], - ], - ); - } - - /// 分隔竖线:同字号 w400 + 45% 透明白,靠两侧空格留白。 - /// 必须用 TextSpan:WidgetSpan 占位盒的基线对齐会让第n集与简介错位。 - TextSpan _separatorSpan() => TextSpan( - text: ' | ', - style: _lineStyle.copyWith( - color: AppColors.onPrimary.withValues(alpha: 0.45), - fontWeight: FontWeight.w400, ), ); - - Widget _toggle(String label, {required EdgeInsets padding}) { - return GestureDetector( - key: const Key('player-synopsis-toggle'), - behavior: HitTestBehavior.opaque, - onTap: () => setState(() => _expanded = !_expanded), - child: Padding( - padding: padding, - child: Text(label, maxLines: 1, style: _linkStyle), - ), - ); - } } 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/core/image_placeholder_shimmer_test.dart b/test/core/image_placeholder_shimmer_test.dart new file mode 100644 index 0000000..fac596d --- /dev/null +++ b/test/core/image_placeholder_shimmer_test.dart @@ -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(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(find.byType(Image)); + final context = tester.element(find.byType(RemoteImage)); + const loaded = SizedBox(key: Key('loaded')); + expect( + image.frameBuilder!(context, loaded, null, false), + isA(), + ); + expect(image.frameBuilder!(context, loaded, 0, false), same(loaded)); + expect( + image.errorBuilder!(context, Exception('failed'), null), + isNot(isA()), + ); + 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()); + }, + ); +} diff --git a/test/following/following_compact_layout_test.dart b/test/following/following_compact_layout_test.dart new file mode 100644 index 0000000..9de370c --- /dev/null +++ b/test/following/following_compact_layout_test.dart @@ -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 _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); + }); +} diff --git a/test/home/feed_info_hierarchy_test.dart b/test/home/feed_info_hierarchy_test.dart new file mode 100644 index 0000000..04140e1 --- /dev/null +++ b/test/home/feed_info_hierarchy_test.dart @@ -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 _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 _spanStyles(InlineSpan? span) sync* { + if (span is! TextSpan) return; + if (span.style != null) yield span.style!; + for (final child in span.children ?? const []) { + yield* _spanStyles(child); + } +} + +void _expectNoShadows(WidgetTester tester) { + final panel = find.byKey(const Key('home-feed-info-panel')); + for (final text in tester.widgetList( + 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 [], 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(expanded).textSpan?.style?.color, + AppColors.overlaySecondary, + ); + _expectNoShadows(tester); + _expectWatchGap(tester); + _expectSynopsisTopGap(tester, expanded); +} + +void main() { + testWidgets('should_render_complete_chinese_toggle_when_theme_font_applies', ( + tester, + ) async { + await _pump(tester, language: 'zh', width: 320, scale: 1.3); + final toggle = find.byKey(const Key('home-feed-synopsis-more')); + final rect = tester.getRect(toggle); + for (final label in ['展开', '收起']) { + final rich = find.descendant(of: toggle, matching: find.byType(RichText)); + final paragraph = tester.widget(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( + find.descendant( + of: find.byKey(const Key('home-feed-tag-Romance')), + matching: find.byType(Text), + ), + ); + expect(tag.style?.color, AppColors.overlaySecondary); + }); + + testWidgets('should_skip_motion_when_system_disables_animations', ( + tester, + ) async { + tester.platformDispatcher.accessibilityFeaturesTestValue = + const FakeAccessibilityFeatures(disableAnimations: true); + addTearDown(tester.platformDispatcher.clearAccessibilityFeaturesTestValue); + await _pump(tester); + expect(find.byKey(const Key('home-feed-synopsis-size')), findsNothing); + await tester.tap(find.byKey(const Key('home-feed-synopsis-more'))); + await tester.pumpAndSettle(); + _expectExpandedSynopsis(tester); + expect(tester.takeException(), isNull); + }); + + testWidgets('should_reverse_cleanly_when_toggling_during_motion', ( + tester, + ) async { + await _pump(tester); + final panel = find.byKey(const Key('home-feed-info-panel')); + final height = tester.getSize(panel).height; + final toggle = find.byKey(const Key('home-feed-synopsis-more')); + final position = tester.getRect(toggle); + for (final _ in [false, true]) { + await tester.tap(toggle); + await tester.pump(); + await tester.pump(_motionSample); + expect(tester.getRect(toggle), position); + } + await tester.pumpAndSettle(); + expect(find.byKey(const Key('home-feed-synopsis-expanded')), findsNothing); + expect(tester.getSize(panel).height, closeTo(height, _epsilon)); + _expectWatchGap(tester); + expect(tester.takeException(), isNull); + }); + + testWidgets('should_omit_toggle_when_synopsis_fits_two_lines', ( + tester, + ) async { + await _pump( + tester, + drama: const Drama( + id: 'short-synopsis', + title: 'Short', + synopsis: 'A short story.', + posterUrl: '', + ), + ); + expect(find.byKey(const Key('home-feed-synopsis')), findsOneWidget); + expect(find.byKey(const Key('home-feed-synopsis-more')), findsNothing); + _expectWatchGap(tester); + expect(tester.takeException(), isNull); + }); + + testWidgets('should_remove_tag_surfaces_when_rendering_types', ( + tester, + ) async { + await _pump(tester); + for (final key in ['home-feed-tag-Urban', 'home-feed-tag-Romance']) { + expect(find.byKey(Key(key)), findsOneWidget); + expect( + find.descendant( + of: find.byKey(Key(key)), + matching: find.byType(DecoratedBox), + ), + findsNothing, + ); + } + _expectNoShadows(tester); + }); + + testWidgets('should_use_small_radius_and_brand_gradient_when_watching_all', ( + tester, + ) async { + await _pump(tester); + final background = find.byKey(const Key('home-feed-full-series-bg')); + final decorated = tester.widget( + 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(find.text('Watch all')).style?.fontSize, 16); + expect( + tester.widget(find.text('Watch all')).style?.fontWeight, + FontWeight.w400, + ); + final clip = tester.widget( + 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( + 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( + 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( + find.byKey(const Key('home-feed-synopsis')), + ); + expect(synopsis.style?.color, AppColors.overlaySecondary); + expect(synopsis.style?.fontSize, 15); + expect(synopsis.maxLines, 2); + final episode = tester.widget( + 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(find.text('Romance')); + expect(tag.style?.color, AppColors.overlaySecondary); + expect(tag.style?.color, synopsis.style?.color); + expect(tag.style?.fontSize, 13); + final hotSearch = tester.widget( + 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( + 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( + 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); + }); +} diff --git a/test/home/feed_types_alignment_test.dart b/test/home/feed_types_alignment_test.dart new file mode 100644 index 0000000..5844d38 --- /dev/null +++ b/test/home/feed_types_alignment_test.dart @@ -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 _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(rich); + final widget = tester.widget(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); + }, + ); + } + } + } +} diff --git a/test/home/feed_watch_all_gradient_test.dart b/test/home/feed_watch_all_gradient_test.dart new file mode 100644 index 0000000..3343822 --- /dev/null +++ b/test/home/feed_watch_all_gradient_test.dart @@ -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 _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( + 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)); + }); + }); +} 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/player/expandable_synopsis_test.dart b/test/player/expandable_synopsis_test.dart new file mode 100644 index 0000000..f307808 --- /dev/null +++ b/test/player/expandable_synopsis_test.dart @@ -0,0 +1,118 @@ +// Author: fruit.com.cn +// 职责:验证共用简介的短文案、展开声明、内容更新与减少动画行为。 +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/theme/app_theme.dart'; +import 'package:fruitshorts/l10n/app_localizations.dart'; +import 'package:fruitshorts/pages/player/widgets/expandable_synopsis.dart'; + +const _width = 300.0; +const _shortCopy = 'A short story.'; +const _longCopy = + 'A hidden promise brings two families together after years apart. ' + 'Every secret leads them closer to an unexpected truth. ' + 'Their journey through the city changes everything they once believed.'; +const _toggle = Key('player-synopsis-more'); +const _expanded = Key('player-synopsis-expanded'); +const _collapsed = Key('player-synopsis'); +const _footer = Text('Author declaration', key: Key('test-author-note')); +const _motionSample = Duration(milliseconds: 80); + +Future _pump( + WidgetTester tester, { + String synopsis = _longCopy, + bool disableAnimations = false, +}) async { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.dark(), + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(disableAnimations: disableAnimations), + child: Scaffold( + body: Align( + alignment: Alignment.bottomLeft, + child: SizedBox( + width: _width, + child: ExpandableSynopsis( + synopsis: synopsis, + keyPrefix: 'player', + expandedFooter: _footer, + ), + ), + ), + ), + ), + ), + ), + ); +} + +void main() { + testWidgets('should_hide_toggle_when_synopsis_fits_two_lines', ( + tester, + ) async { + await _pump(tester, synopsis: _shortCopy); + expect(find.byKey(_toggle), findsNothing); + expect(tester.widget(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); + }); +} diff --git a/test/player/interaction_rail_asset_motion_test.dart b/test/player/interaction_rail_asset_motion_test.dart new file mode 100644 index 0000000..db53462 --- /dev/null +++ b/test/player/interaction_rail_asset_motion_test.dart @@ -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(_image); + final rail = tester.renderObject(find.byKey(_itemKey)); + return MatrixUtils.transformRect( + icon.getTransformTo(rail), + Offset.zero & icon.size, + ).width; +} + +Future _pumpAssetRail( + WidgetTester tester, + ValueNotifier selected, +) => tester.pumpWidget( + MaterialApp( + theme: AppTheme.dark(), + home: Scaffold( + body: Center( + child: ValueListenableBuilder( + 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(_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(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 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; +} diff --git a/test/player/interaction_rail_motion_test.dart b/test/player/interaction_rail_motion_test.dart new file mode 100644 index 0000000..dc61ae7 --- /dev/null +++ b/test/player/interaction_rail_motion_test.dart @@ -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 _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( + 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(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(find.byKey(_burstKey)).contracting, + isFalse, + ); + await tester.tap(_button(_likeKey)); + await tester.pump(); + await tester.pump(_popFrame); + final feedback = tester.widget(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); + }); +} diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index f9222b3..9b950f2 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -1271,6 +1271,22 @@ void main() { expect(find.byKey(const Key('player-interaction-rail')), findsOneWidget); }); + testWidgets('should_keep_episode_entry_regular_when_player_is_open', ( + tester, + ) async { + await _pumpApp(tester); + await _openSeriesPlayer(tester); + final entry = tester.widget( + find.descendant( + of: find.byKey(const Key('player-episodes-pill')), + matching: find.byType(Text), + ), + ); + expect(entry.style?.fontWeight, FontWeight.w400); + expect(entry.style?.fontSize, 13.5); + expect(entry.data, contains(' · ')); + }); + testWidgets('should_push_episodes_arrow_to_pill_right', (tester) async { // 宽屏下文案不会撑满胶囊,才能验证箭头是否被推到最右侧。 await _pumpApp(tester, size: const Size(900, 844)); @@ -1371,9 +1387,9 @@ void main() { final shareRect = tester.getRect( find.byKey(const Key('player-rail-share')), ); - expect(likeRect.top - saveRect.bottom, closeTo(12, 0.1)); - expect(commentsRect.top - likeRect.bottom, closeTo(12, 0.1)); - expect(shareRect.top - commentsRect.bottom, closeTo(12, 0.1)); + expect(likeRect.top - saveRect.bottom, closeTo(6, 0.1)); + expect(commentsRect.top - likeRect.bottom, closeTo(6, 0.1)); + expect(shareRect.top - commentsRect.bottom, closeTo(6, 0.1)); final saveButton = tester.widget( find.descendant( @@ -1405,7 +1421,9 @@ void main() { ); }); - testWidgets('should_toggle_synopsis_with_inline_text_button', (tester) async { + testWidgets('should_toggle_synopsis_at_fixed_corner_with_feed_motion', ( + tester, + ) async { final longCopy = Drama( id: 'long-copy-demo', title: 'Long Title', @@ -1428,7 +1446,7 @@ void main() { await _pumpApp(tester, catalog: FakeCatalogRepository(dramas: [longCopy])); await _openSeriesPlayer(tester, feedIndex: 0); - final toggle = find.byKey(const Key('player-synopsis-toggle')); + final toggle = find.byKey(const Key('player-synopsis-more')); final synopsis = find.byKey(const Key('player-synopsis')); expect( find.descendant(of: toggle, matching: find.text('More')), @@ -1443,17 +1461,15 @@ void main() { ?.color, AppColors.textPrimary, ); - expect(tester.widget(synopsis).maxLines, 1); + expect(tester.widget(synopsis).maxLines, 2); expect(tester.widget(synopsis).overflow, TextOverflow.ellipsis); - // 行尾「展开」与正文同一行,只留 6 的左边距。 - expect( - tester - .widget( - find.descendant(of: toggle, matching: find.byType(Padding)), - ) - .padding, - const EdgeInsets.only(left: 6), + final motion = tester.widget( + find.byKey(const Key('player-synopsis-size')), ); + expect(motion.duration, const Duration(milliseconds: 240)); + expect(motion.curve, Curves.easeInOutCubic); + final blockFinder = find.byKey(const Key('player-episode-synopsis')); + final collapsedHeight = tester.getSize(blockFinder).height; final synopsisRect = tester.getRect(synopsis); final toggleRect = tester.getRect(toggle); expect(toggleRect.left, greaterThan(synopsisRect.left)); @@ -1463,24 +1479,24 @@ void main() { ); await tester.tap(toggle); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 80)); + final growingHeight = tester.getSize(blockFinder).height; + expect(growingHeight, greaterThan(collapsedHeight)); + expect(tester.getRect(toggle), toggleRect); await tester.pumpAndSettle(); expect( find.descendant(of: toggle, matching: find.text('Less')), findsOneWidget, ); expect( - tester.widget(find.byKey(const Key('player-synopsis'))).maxLines, + tester + .widget(find.byKey(const Key('player-synopsis-expanded'))) + .maxLines, isNull, ); - final lessRect = tester.getRect(toggle); - final textRect = tester.getRect(find.byKey(const Key('player-synopsis'))); - final block = tester.getRect( - find.byKey(const Key('player-episode-synopsis')), - ); - // 展开后「收起」内联在正文最后一行的行尾。 - expect(lessRect.top, greaterThanOrEqualTo(textRect.top)); - expect(lessRect.bottom, lessThanOrEqualTo(textRect.bottom + 1)); - expect(lessRect.right - block.right, lessThanOrEqualTo(2)); + expect(tester.getRect(toggle), toggleRect); + expect(tester.getSize(blockFinder).height, greaterThan(growingHeight)); await tester.tap(toggle); await tester.pumpAndSettle(); @@ -1488,29 +1504,24 @@ void main() { find.descendant(of: toggle, matching: find.text('More')), findsOneWidget, ); - expect(tester.widget(synopsis).maxLines, 1); + expect(tester.widget(synopsis).maxLines, 2); }); - testWidgets('should_share_one_text_style_across_label_and_synopsis', ( + testWidgets('should_match_feed_synopsis_without_episode_prefix', ( tester, ) async { await _pumpApp(tester); await _openSeriesPlayer(tester); - // 新 UI 把「第 n 集」与简介合并进同一个富文本,整行共用一套白字规格。 final line = tester.widget(find.byKey(const Key('player-synopsis'))); final style = line.style ?? const TextStyle(); - expect(style.fontSize, 12); - expect(style.fontWeight, FontWeight.w500); - expect(style.color, AppColors.onPrimary); - - final span = line.textSpan! as TextSpan; - final separator = span.children!.whereType().firstWhere( - (child) => (child.text ?? '').contains('|'), - ); - expect(separator.style?.fontSize, 12); - expect(separator.style?.fontWeight, FontWeight.w400); - expect(separator.style?.color, AppColors.onPrimary.withValues(alpha: 0.45)); + expect(style.fontSize, 15); + expect(style.fontWeight, FontWeight.w400); + expect(style.color, AppColors.overlaySecondary); + expect(style.shadows ?? [], isEmpty); + expect(line.textSpan!.toPlainText(), isNot(startsWith('Episode '))); + expect(line.textSpan!.toPlainText(), isNot(contains(' | '))); + expect(find.byKey(const Key('player-episode-heading')), findsOneWidget); }); testWidgets('should_fit_long_title_and_synopsis_at_320_width', ( @@ -1563,7 +1574,7 @@ void main() { tester.getRect(find.byKey(const Key('player-synopsis'))).right, lessThanOrEqualTo(320 - 14 - 54), ); - expect(synopsis.maxLines, 1); + expect(synopsis.maxLines, 2); expect(synopsis.overflow, TextOverflow.ellipsis); expect( tester.getRect(find.byKey(const Key('player-episodes-pill'))).right, 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)); + }); +}