diff --git a/lib/core/theme/app_dimens.dart b/lib/core/theme/app_dimens.dart index 7e4e72a..8ebd243 100644 --- a/lib/core/theme/app_dimens.dart +++ b/lib/core/theme/app_dimens.dart @@ -157,17 +157,9 @@ abstract final class AppDimens { /// 首页热播榜。 static const double rankingHeroTitle = 22; static const double rankingHeroSubtitle = 11; - static const double rankingPodiumChampionWidth = 110; - static const double rankingPodiumChampionHeight = 146; - static const double rankingPodiumWidth = 92; - static const double rankingPodiumHeight = 122; - static const double rankingPodiumRadius = 10; - static const double rankingRowHeight = 96; - static const double rankingNumberWidth = 56; - static const double rankingNumberFont = 48; - static const double rankingCoverWidth = 60; - static const double rankingCoverHeight = 80; static const double rankingCoverRadius = 6; + static const double rankingCardCoverWidth = 96; + static const double rankingCardCoverHeight = 128; /// 全部短剧筛选与追剧双列。 static const double filterChipFont = 16; diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 8440a85..4633e72 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -83,6 +83,12 @@ "count": {"type": "String"} } }, + "likesLabel": "{count} likes", + "@likesLabel": { + "placeholders": { + "count": {"type": "String"} + } + }, "topCharts": "Hot Ranking", "newReleases": "New Releases", "newDramas": "New", @@ -359,7 +365,7 @@ "categoriesError": "Categories could not be loaded.", "detailError": "The episode list could not be loaded.", "noMediaNotice": "This episode has no playable media yet.", - "noStories": "No stories available yet.", + "noStories": "No stories available yet", "notices": "Notices", "noNotices": "No notices yet.", "noticesLoadFailed": "Notices could not be loaded.", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 21c9b2b..1f9f6d6 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -72,6 +72,7 @@ "authorNoteLabel": "Catatan penulis", "reserve": "Reservasi", "heatLabel": "{count} panas", + "likesLabel": "{count} suka", "topCharts": "Peringkat Populer", "newReleases": "Rilisan Baru", "newDramas": "Baru", @@ -338,7 +339,7 @@ "categoriesError": "Kategori gagal dimuat.", "detailError": "Daftar episode gagal dimuat.", "noMediaNotice": "Episode ini belum punya media yang bisa diputar.", - "noStories": "Belum ada cerita.", + "noStories": "Belum ada cerita", "notices": "Notifikasi", "noNotices": "Belum ada notifikasi.", "noticesLoadFailed": "Notifikasi tidak dapat dimuat.", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 01d871a..ed98fdc 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -442,6 +442,12 @@ abstract class AppLocalizations { /// **'{count} heat'** String heatLabel(String count); + /// No description provided for @likesLabel. + /// + /// In en, this message translates to: + /// **'{count} likes'** + String likesLabel(String count); + /// No description provided for @topCharts. /// /// In en, this message translates to: @@ -1393,7 +1399,7 @@ abstract class AppLocalizations { /// No description provided for @noStories. /// /// In en, this message translates to: - /// **'No stories available yet.'** + /// **'No stories available yet'** String get noStories; /// No description provided for @notices. diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 6ec70d6..58cdf14 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -191,6 +191,11 @@ class AppLocalizationsEn extends AppLocalizations { return '$count heat'; } + @override + String likesLabel(String count) { + return '$count likes'; + } + @override String get topCharts => 'Hot Ranking'; @@ -709,7 +714,7 @@ class AppLocalizationsEn extends AppLocalizations { String get noMediaNotice => 'This episode has no playable media yet.'; @override - String get noStories => 'No stories available yet.'; + String get noStories => 'No stories available yet'; @override String get notices => 'Notices'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index d09f864..d77c7ff 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -191,6 +191,11 @@ class AppLocalizationsId extends AppLocalizations { return '$count panas'; } + @override + String likesLabel(String count) { + return '$count suka'; + } + @override String get topCharts => 'Peringkat Populer'; @@ -712,7 +717,7 @@ class AppLocalizationsId extends AppLocalizations { 'Episode ini belum punya media yang bisa diputar.'; @override - String get noStories => 'Belum ada cerita.'; + String get noStories => 'Belum ada cerita'; @override String get notices => 'Notifikasi'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index bd48256..12b9a6c 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -189,6 +189,11 @@ class AppLocalizationsZh extends AppLocalizations { return '$count热度'; } + @override + String likesLabel(String count) { + return '$count次点赞'; + } + @override String get topCharts => '热播榜'; @@ -702,7 +707,7 @@ class AppLocalizationsZh extends AppLocalizations { String get noMediaNotice => '该剧集还没有可播放的片源。'; @override - String get noStories => '暂无剧目。'; + String get noStories => '暂无剧目'; @override String get notices => '通知'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 982c85f..09377b6 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -72,6 +72,7 @@ "authorNoteLabel": "作者声明", "reserve": "预约", "heatLabel": "{count}热度", + "likesLabel": "{count}次点赞", "topCharts": "热播榜", "newReleases": "新剧", "newDramas": "新剧", @@ -300,7 +301,7 @@ "categoriesError": "分类加载失败。", "detailError": "剧集信息加载失败。", "noMediaNotice": "该剧集还没有可播放的片源。", - "noStories": "暂无剧目。", + "noStories": "暂无剧目", "notices": "通知", "noNotices": "暂无通知。", "noticesLoadFailed": "通知加载失败,请重试。", diff --git a/lib/pages/home/widgets/home_ranking_dramas.dart b/lib/pages/home/widgets/home_ranking_dramas.dart new file mode 100644 index 0000000..443dc6c --- /dev/null +++ b/lib/pages/home/widgets/home_ranking_dramas.dart @@ -0,0 +1,173 @@ +// Author: fruit.com.cn + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../../core/data/models/category_ranking.dart'; +import '../../../core/data/models/drama.dart'; +import '../../../core/data/services/category_controller.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_dimens.dart'; +import '../../../core/widgets/app_pull_to_refresh.dart'; +import '../../../core/widgets/drama_grid_skeleton.dart'; +import '../../../core/widgets/empty_state.dart'; +import '../../../l10n/app_localizations.dart'; +import '../../ranking/widgets/ranking_drama_row.dart'; + +/// 榜单页顶部暗红光晕,向下淡入纯黑(与剧场榜单页一致)。 +const BoxDecoration _rankingBackdrop = BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + AppColors.followingGlow, + AppColors.followingGlowMid, + AppColors.appBackground, + ], + stops: [0, 0.14, 0.3], + ), +); + +/// 推荐榜频道:服务端排名快照,统一行布局,名次角标压在封面左上角。 +class HomeRankingDramas extends StatefulWidget { + const HomeRankingDramas({ + super.key, + required this.categoryId, + required this.onOpenDrama, + this.topPadding = AppDimens.spaceLg, + }); + + final String categoryId; + final ValueChanged onOpenDrama; + final double topPadding; + + @override + State createState() => _HomeRankingDramasState(); +} + +class _HomeRankingDramasState extends State { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _ensureLoaded()); + } + + @override + void didUpdateWidget(HomeRankingDramas oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.categoryId != widget.categoryId) _ensureLoaded(); + } + + void _ensureLoaded() { + if (!mounted) return; + final controller = context.read(); + final slot = controller.rankingSlotOf(widget.categoryId); + if (slot.loading || slot.ranking != null) return; + unawaited(controller.openRanking(widget.categoryId)); + } + + @override + Widget build(BuildContext context) { + final controller = context.watch(); + final slot = controller.rankingSlotOf(widget.categoryId); + final entries = slot.ranking?.entries ?? const []; + if (slot.loading && entries.isEmpty) { + return const DecoratedBox( + key: Key('home-ranking-surface'), + decoration: _rankingBackdrop, + child: DramaGridSkeleton( + key: Key('home-ranking-skeleton'), + columns: 3, + horizontalPadding: AppDimens.feedPageHorizontal, + crossAxisSpacing: AppDimens.homeGridGap, + mainAxisSpacing: AppDimens.homeGridRowGap, + ), + ); + } + return DecoratedBox( + key: const Key('home-ranking-surface'), + decoration: _rankingBackdrop, + child: Material( + type: MaterialType.transparency, + child: AppPullToRefresh( + key: const Key('home-ranking-refresh'), + onRefresh: () => controller.openRanking(widget.categoryId), + child: _content(context, entries, slot.error), + ), + ), + ); + } + + Widget _content( + BuildContext context, + List entries, + Object? error, + ) { + final l10n = AppLocalizations.of(context)!; + if (entries.isEmpty) { + return CustomScrollView( + key: PageStorageKey('home-ranking-scroll-${widget.categoryId}'), + physics: AppPullToRefresh.physics, + slivers: [ + SliverFillRemaining( + hasScrollBody: false, + child: EmptyState( + key: Key( + error == null ? 'home-ranking-empty' : 'home-ranking-error', + ), + icon: error == null + ? Icons.leaderboard_outlined + : Icons.cloud_off_rounded, + title: error == null ? l10n.emptyContent : l10n.catalogError, + actionLabel: error == null ? null : l10n.retry, + onAction: error == null + ? null + : () => unawaited( + context.read().openRanking( + widget.categoryId, + ), + ), + stretchContent: true, + ), + ), + ], + ); + } + return CustomScrollView( + key: PageStorageKey('home-ranking-scroll-${widget.categoryId}'), + physics: AppPullToRefresh.physics, + slivers: [ + SliverPadding( + padding: EdgeInsets.fromLTRB( + AppDimens.feedPageHorizontal, + widget.topPadding, + AppDimens.feedPageHorizontal, + AppDimens.spaceXl, + ), + sliver: SliverList.separated( + itemCount: entries.length, + itemBuilder: (context, index) => _row(entries[index]), + separatorBuilder: (_, _) => const Divider( + height: AppDimens.mineGridDividerWidth, + thickness: AppDimens.mineGridDividerWidth, + color: AppColors.border, + ), + ), + ), + ], + ); + } + + Widget _row(CategoryRankingEntry entry) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: AppDimens.spaceSm), + child: RankingDramaRow( + rank: entry.rank, + drama: entry.drama, + onTap: () => widget.onOpenDrama(entry.drama), + ), + ); + } +} diff --git a/lib/pages/ranking/ranking_page.dart b/lib/pages/ranking/ranking_page.dart index 898cf2a..231c788 100644 --- a/lib/pages/ranking/ranking_page.dart +++ b/lib/pages/ranking/ranking_page.dart @@ -7,26 +7,20 @@ import 'package:provider/provider.dart'; import '../../core/data/models/drama.dart'; import '../../core/data/services/catalog_controller.dart'; -import '../../core/formatting/count_format.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_dimens.dart'; import '../../core/widgets/app_pull_to_refresh.dart'; import '../../core/widgets/empty_state.dart'; import '../../core/widgets/paged_local_list.dart'; import '../../core/widgets/plain_page_scaffold.dart'; -import '../../core/widgets/remote_image.dart'; import '../../l10n/app_localizations.dart'; import '../player/open_player.dart'; +import 'widgets/ranking_drama_row.dart'; const int _topCount = 50; const double _loadMoreThreshold = 200; -const double _championLift = 12; -const double _podiumGap = 8; -const double _podiumHorizontal = 8; -const double _championScale = 1.18; -const double _coverAspect = 146 / 110; -/// 剧场榜单页:前三名领奖台,第 4 名起为单列排行行。 +/// 剧场榜单页:统一行布局,名次角标压在封面左上角,右侧字段无数据不展示。 class RankingPage extends StatefulWidget { const RankingPage({super.key, this.embedded = false}); @@ -101,8 +95,6 @@ class _RankingPageState extends State { final l10n = AppLocalizations.of(context)!; final catalog = context.watch(); final top = _pager!.visibleItems; - final podium = top.take(3).toList(growable: false); - final rest = top.skip(3).toList(growable: false); final content = NotificationListener( onNotification: _onScroll, child: AppPullToRefresh( @@ -118,38 +110,31 @@ class _RankingPageState extends State { : [ if (widget.embedded) _intro(l10n), const SizedBox(height: AppDimens.spaceMd), - if (podium.isNotEmpty) + for (var index = 0; index < top.length; index++) ...[ Padding( padding: const EdgeInsets.symmetric( - horizontal: _podiumHorizontal, + horizontal: AppDimens.feedPageHorizontal, + vertical: AppDimens.spaceSm, + ), + child: RankingDramaRow( + rank: index + 1, + drama: top[index], + onTap: () => unawaited( + playDramaFromCatalog(context, top[index]), + ), ), - child: _Podium(podium: podium, l10n: l10n), ), - if (rest.isNotEmpty) ...[ - const SizedBox(height: AppDimens.spaceXl), - for (var index = 0; index < rest.length; index++) ...[ - Padding( - padding: const EdgeInsets.symmetric( + if (index != top.length - 1) + const Padding( + padding: EdgeInsets.symmetric( horizontal: AppDimens.feedPageHorizontal, ), - child: _RankingRow( - index: index + 3, - drama: rest[index], - l10n: l10n, + child: Divider( + height: AppDimens.mineGridDividerWidth, + thickness: AppDimens.mineGridDividerWidth, + color: AppColors.border, ), ), - if (index != rest.length - 1) - const Padding( - padding: EdgeInsets.symmetric( - horizontal: AppDimens.feedPageHorizontal, - ), - child: Divider( - height: AppDimens.mineGridDividerWidth, - thickness: AppDimens.mineGridDividerWidth, - color: AppColors.border, - ), - ), - ], ], PagedLoadFooter( loading: _pager!.loading, @@ -221,302 +206,9 @@ class _RankingPageState extends State { title: catalog.error == null ? l10n.noStories : l10n.catalogError, actionLabel: catalog.error == null ? null : l10n.retry, onAction: catalog.error == null ? null : () => unawaited(_refresh()), + stretchContent: true, ), ), ); } } - -class _Podium extends StatelessWidget { - const _Podium({required this.podium, required this.l10n}); - - final List podium; - final AppLocalizations l10n; - - @override - Widget build(BuildContext context) { - return LayoutBuilder( - builder: (context, constraints) { - final available = constraints.maxWidth - _podiumGap * 2; - final side = available / (2 + _championScale); - final champion = side * _championScale; - return Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - SizedBox( - width: side, - child: podium.length > 1 - ? _PodiumCard( - rank: 2, - drama: podium[1], - l10n: l10n, - coverWidth: side, - ) - : const SizedBox.shrink(), - ), - const SizedBox(width: _podiumGap), - SizedBox( - width: champion, - child: podium.isNotEmpty - ? Transform.translate( - offset: const Offset(0, -_championLift), - child: _PodiumCard( - rank: 1, - drama: podium.first, - l10n: l10n, - coverWidth: champion, - ), - ) - : const SizedBox.shrink(), - ), - const SizedBox(width: _podiumGap), - SizedBox( - width: side, - child: podium.length > 2 - ? _PodiumCard( - rank: 3, - drama: podium[2], - l10n: l10n, - coverWidth: side, - ) - : const SizedBox.shrink(), - ), - ], - ); - }, - ); - } -} - -class _PodiumCard extends StatelessWidget { - const _PodiumCard({ - required this.rank, - required this.drama, - required this.l10n, - required this.coverWidth, - }); - - final int rank; - final Drama drama; - final AppLocalizations l10n; - final double coverWidth; - - double get _coverHeight => coverWidth * _coverAspect; - - Color get _badgeColor => switch (rank) { - 1 => AppColors.homeCrownGold, - 2 => AppColors.rankSilver, - _ => AppColors.bronze, - }; - - Color get _badgeTextColor => - rank == 3 ? AppColors.textPrimary : AppColors.onGold; - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: () => unawaited(playDramaFromCatalog(context, drama)), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(AppDimens.rankingPodiumRadius), - child: SizedBox( - key: Key('ranking-cover-$rank'), - width: coverWidth, - height: _coverHeight, - child: Stack( - fit: StackFit.expand, - children: [ - RemoteImage( - drama.posterUrl, - fit: BoxFit.cover, - showFallbackIcon: true, - fallbackAspectRatio: 3 / 4, - ), - Positioned( - top: 4, - left: 4, - child: Container( - key: Key('ranking-top-$rank'), - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - decoration: BoxDecoration( - color: _badgeColor, - borderRadius: BorderRadius.circular(6), - ), - child: Text( - l10n.topLabel(rank), - maxLines: 1, - style: TextStyle( - color: _badgeTextColor, - fontSize: 11, - fontWeight: FontWeight.w800, - ), - ), - ), - ), - ], - ), - ), - ), - const SizedBox(height: 6), - SizedBox( - height: 34, - child: Text( - drama.title, - key: Key('ranking-title-$rank'), - maxLines: 2, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.center, - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: 13, - fontWeight: FontWeight.w600, - height: 1.25, - ), - ), - ), - if (drama.viewCount > 0) ...[ - const SizedBox(height: 2), - _heat(drama.viewCount, l10n), - ], - ], - ), - ); - } -} - -class _RankingRow extends StatelessWidget { - const _RankingRow({ - required this.index, - required this.drama, - required this.l10n, - }); - - final int index; - final Drama drama; - final AppLocalizations l10n; - - int get _rank => index + 1; - - String get _category { - final values = {}; - for (final value in [drama.genre, drama.category]) { - for (final part in value.split(RegExp(r'[,/、·]'))) { - final tag = part.trim(); - if (tag.isNotEmpty) values.add(tag); - } - } - return values.join(' · '); - } - - @override - Widget build(BuildContext context) { - return SizedBox( - height: AppDimens.rankingRowHeight, - child: Material( - color: AppColors.transparent, - child: InkWell( - key: Key('ranking-row-$index'), - onTap: () => unawaited(playDramaFromCatalog(context, drama)), - child: Row( - children: [ - SizedBox( - width: AppDimens.rankingNumberWidth, - child: Text( - '$_rank', - key: Key('ranking-top-$_rank'), - textAlign: TextAlign.center, - maxLines: 1, - style: TextStyle( - color: AppColors.textPrimary.withValues(alpha: 0.12), - fontSize: AppDimens.rankingNumberFont, - fontWeight: FontWeight.w800, - height: 1, - ), - ), - ), - ClipRRect( - borderRadius: BorderRadius.circular( - AppDimens.rankingCoverRadius, - ), - child: SizedBox( - key: Key('ranking-row-cover-$_rank'), - width: AppDimens.rankingCoverWidth, - height: AppDimens.rankingCoverHeight, - child: RemoteImage( - drama.posterUrl, - fit: BoxFit.cover, - showFallbackIcon: true, - fallbackAspectRatio: 3 / 4, - ), - ), - ), - const SizedBox(width: AppDimens.spaceMd), - Expanded( - child: SizedBox( - height: AppDimens.rankingCoverHeight, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - drama.title, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: 15, - fontWeight: FontWeight.w500, - ), - ), - if (_category.isNotEmpty) ...[ - const SizedBox(height: AppDimens.spaceXs), - Text( - _category, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.textTertiary, - fontSize: 11, - ), - ), - ], - const Spacer(), - if (drama.viewCount > 0) _heat(drama.viewCount, l10n), - ], - ), - ), - ), - ], - ), - ), - ), - ); - } -} - -Widget _heat(int count, AppLocalizations l10n) { - return Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon( - Icons.local_fire_department_rounded, - color: AppColors.textTertiary, - size: 11, - ), - const SizedBox(width: 4), - Flexible( - child: Text( - l10n.heatLabel(formatPlayCount(count)), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(color: AppColors.textTertiary, fontSize: 11), - ), - ), - ], - ); -} diff --git a/lib/pages/ranking/widgets/ranking_drama_row.dart b/lib/pages/ranking/widgets/ranking_drama_row.dart new file mode 100644 index 0000000..eeb4b79 --- /dev/null +++ b/lib/pages/ranking/widgets/ranking_drama_row.dart @@ -0,0 +1,229 @@ +// Author: fruit.com.cn +// 榜单统一行:名次角标压在封面左上角,右侧字段无数据不展示。 + +import 'package:flutter/material.dart'; + +import '../../../core/data/models/drama.dart'; +import '../../../core/formatting/count_format.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_dimens.dart'; +import '../../../core/widgets/remote_image.dart'; +import '../../../l10n/app_localizations.dart'; + +const double _badgeSize = 24; +const double _badgeCornerRadius = 10; +const double _badgeFontSize = 14; +const double _titleFontSize = 16; +const double _metaLineGap = 2; +const double _lineHeight = 1.25; +const int _synopsisMaxLines = 3; +const double _metaFontSize = 12; +const double _tagFontSize = 10; +const double _flameSize = 13; +const double _flameGap = 2; +const double _badgeMutedAlpha = 0.6; +const double _badgeTextHeight = 1; + +/// 榜单行:左侧 3:4 封面带名次角标,右侧标题/分类/简介/底行元信息。 +class RankingDramaRow extends StatelessWidget { + const RankingDramaRow({ + super.key, + required this.rank, + required this.drama, + required this.onTap, + }); + + final int rank; + final Drama drama; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return SizedBox( + height: AppDimens.rankingCardCoverHeight, + child: Material( + color: AppColors.transparent, + child: InkWell( + key: Key('ranking-row-${rank - 1}'), + onTap: onTap, + child: Row( + children: [ + _cover(), + const SizedBox(width: AppDimens.spaceMd), + Expanded(child: _info(l10n)), + ], + ), + ), + ), + ); + } + + String get _category { + final values = {}; + for (final value in [drama.genre, drama.category]) { + for (final part in value.split(RegExp(r'[,/、·]'))) { + final tag = part.trim(); + if (tag.isNotEmpty) values.add(tag); + } + } + return values.join(' · '); + } + + Color get _badgeColor => switch (rank) { + 1 => AppColors.brandRed, + 2 => AppColors.homeCrownGold, + 3 => AppColors.bronze, + _ => AppColors.ink.withValues(alpha: _badgeMutedAlpha), + }; + + Widget _badge() { + return Container( + key: Key('ranking-top-$rank'), + width: _badgeSize, + height: _badgeSize, + alignment: Alignment.center, + decoration: BoxDecoration( + color: _badgeColor, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(AppDimens.rankingCoverRadius), + bottomRight: Radius.circular(_badgeCornerRadius), + ), + ), + child: Text( + '$rank', + maxLines: 1, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: _badgeFontSize, + fontWeight: FontWeight.w700, + height: _badgeTextHeight, + ), + ), + ); + } + + Widget _cover() { + return ClipRRect( + borderRadius: BorderRadius.circular(AppDimens.rankingCoverRadius), + child: SizedBox( + key: Key('ranking-row-cover-$rank'), + width: AppDimens.rankingCardCoverWidth, + height: AppDimens.rankingCardCoverHeight, + child: Stack( + fit: StackFit.expand, + children: [ + RemoteImage( + drama.posterUrl, + fit: BoxFit.cover, + fallbackWidget: const ColoredBox(color: AppColors.raised), + ), + Positioned(top: 0, left: 0, child: _badge()), + ], + ), + ), + ); + } + + Widget _info(AppLocalizations l10n) { + final category = _category; + final synopsis = drama.synopsis.trim(); + return SizedBox( + height: AppDimens.rankingCardCoverHeight, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + drama.title, + key: Key('ranking-title-$rank'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: _titleFontSize, + fontWeight: FontWeight.w600, + height: _lineHeight, + ), + ), + if (category.isNotEmpty) _metaText(category), + if (synopsis.isNotEmpty) + _metaText(synopsis, maxLines: _synopsisMaxLines), + const SizedBox(height: _metaLineGap), + _bottomMeta(l10n), + ], + ), + ); + } + + Widget _metaText(String text, {int maxLines = 1}) { + return Padding( + padding: const EdgeInsets.only(top: _metaLineGap), + child: Text( + text, + maxLines: maxLines, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textTertiary, + fontSize: _metaFontSize, + height: _lineHeight, + ), + ), + ); + } + + Widget _bottomMeta(AppLocalizations l10n) { + final tag = drama.tag.trim(); + final metas = [ + if (drama.viewCount > 0) l10n.heatLabel(formatPlayCount(drama.viewCount)), + if (drama.likeCount != null) + l10n.likesLabel(formatInteractionCount(drama.likeCount!)), + ]; + if (tag.isEmpty && metas.isEmpty) return const SizedBox.shrink(); + return Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: AppDimens.spaceSm, + runSpacing: _metaLineGap, + children: [ + if (tag.isNotEmpty) _tagText(tag), + if (metas.isNotEmpty) + Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.local_fire_department_rounded, + color: AppColors.actionRed, + size: _flameSize, + ), + const SizedBox(width: _flameGap), + Flexible( + child: Text( + metas.join(' · '), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textTertiary, + fontSize: _metaFontSize, + height: _lineHeight, + ), + ), + ), + ], + ), + ], + ); + } + + Widget _tagText(String tag) { + return Text( + tag, + key: Key('ranking-tag-$rank'), + maxLines: 1, + style: TextStyle( + color: drama.tagRibbonColor(AppColors.brandRed), + fontSize: _tagFontSize, + fontWeight: FontWeight.w600, + height: _lineHeight, + ), + ); + } +} diff --git a/lib/pages/recommend/recommend_discover_page.dart b/lib/pages/recommend/recommend_discover_page.dart index ce9a17f..948ccb9 100644 --- a/lib/pages/recommend/recommend_discover_page.dart +++ b/lib/pages/recommend/recommend_discover_page.dart @@ -10,11 +10,13 @@ import '../../core/data/models/home_feed.dart'; import '../../core/data/remote/catalog_api.dart'; import '../../core/data/services/category_controller.dart'; import '../../core/theme/app_colors.dart'; +import '../../core/theme/app_dimens.dart'; import '../../core/widgets/app_pull_to_refresh.dart'; import '../../core/widgets/empty_state.dart'; import '../../core/widgets/paged_list_footer.dart'; import '../../l10n/app_localizations.dart'; import '../player/open_player.dart'; +import '../home/widgets/home_ranking_dramas.dart'; import 'widgets/home_hero_carousel.dart'; import 'widgets/home_recommend_content.dart'; import 'widgets/recommend_channel_nav.dart'; @@ -100,6 +102,21 @@ class _RecommendDiscoverPageState extends State if (_requestedCategories.contains(id)) return; _requestedCategories.add(id); final categories = context.read(); + final category = categories.categories + .where((item) => item.id == id) + .firstOrNull; + if (category?.isRanking ?? false) { + final slot = categories.rankingSlotOf(id); + if (categories.rankingLoader == null || + slot.ranking != null || + slot.loading) { + return; + } + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) unawaited(categories.openRanking(id)); + }); + return; + } if (categories.dramaPageLoader == null) return; final slot = categories.slotOf(id); if (slot.dramas.isNotEmpty || slot.loading) return; @@ -171,6 +188,16 @@ class _RecommendDiscoverPageState extends State onPageChanged: (i) => _onPageChanged(i, orderedIds), itemBuilder: (context, index) { final category = categoryList[index]; + if (category.isRanking) { + return HomeRankingDramas( + key: PageStorageKey( + 'recommend-ranking-${category.id}', + ), + categoryId: category.id, + topPadding: mediaTop + RecommendChannelNav.height, + onOpenDrama: _openDrama, + ); + } return _ChannelFeed( key: PageStorageKey( 'recommend-channel-${category.id}', @@ -318,21 +345,14 @@ class _ChannelFeedState extends State<_ChannelFeed> sliverList.add( SliverFillRemaining( hasScrollBody: false, - child: Padding( - padding: EdgeInsets.only( - top: showCarousel - ? 0 - : widget.mediaTop + RecommendChannelNav.height, - ), - child: Center(child: _placeholder(slot.loading)), - ), + child: Center(child: _placeholder(slot.loading)), ), ); } else if (widget.isFeatured) { // 无轮播图时内容需避开顶部悬浮导航。 final contentTop = showCarousel ? 24.0 - : widget.mediaTop + RecommendChannelNav.height + 16; + : widget.mediaTop + RecommendChannelNav.height + AppDimens.spaceSm; if (newArrivals.isNotEmpty) { sliverList.add(SliverToBoxAdapter(child: SizedBox(height: contentTop))); sliverList.add( @@ -358,7 +378,9 @@ class _ChannelFeedState extends State<_ChannelFeed> dramas, topPadding: showCarousel ? 20 - : widget.mediaTop + RecommendChannelNav.height + 16, + : widget.mediaTop + + RecommendChannelNav.height + + AppDimens.spaceSm, crossAxisCount: 3, ), ); @@ -411,6 +433,8 @@ class _ChannelFeedState extends State<_ChannelFeed> key: const Key('recommend-category-empty'), icon: Icons.movie_outlined, title: l10n.noStories, + compact: true, + stretchContent: true, ); } diff --git a/test/discover/theater_pages_test.dart b/test/discover/theater_pages_test.dart index 9c57d07..e556a4e 100644 --- a/test/discover/theater_pages_test.dart +++ b/test/discover/theater_pages_test.dart @@ -6,7 +6,9 @@ import 'package:fruitshorts/app.dart'; import 'package:fruitshorts/core/data/models/drama.dart'; import 'package:fruitshorts/core/data/models/home_feed.dart'; import 'package:fruitshorts/core/theme/app_colors.dart'; +import 'package:fruitshorts/core/theme/app_dimens.dart'; import 'package:fruitshorts/core/widgets/app_pull_to_refresh.dart'; +import 'package:fruitshorts/core/widgets/paged_local_list.dart'; import 'package:fruitshorts/pages/discover/discover_view.dart'; import 'package:fruitshorts/pages/discover/widgets/drama_grid.dart'; import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; @@ -34,7 +36,17 @@ void main() { expect(find.byKey(const Key('ranking-page')), findsOneWidget); _expectUnifiedHeader(tester, const Key('ranking-back')); - expect(find.text('TOP 1'), findsOneWidget); + expect( + tester + .widget( + find.descendant( + of: find.byKey(const Key('ranking-top-1')), + matching: find.byType(Text), + ), + ) + .data, + '1', + ); expect( tester.widget(find.byKey(const Key('ranking-title-1'))).data, 'The Signal', @@ -105,51 +117,72 @@ void main() { expect(find.byKey(const Key('new-releases-page')), findsOneWidget); }); - testWidgets('should_render_podium_with_center_on_top', (tester) async { + testWidgets('should_render_uniform_rows_with_cover_badges', (tester) async { await _pumpTheater(tester); await tester.tap(find.byKey(const Key('theater-toolbar-ranking'))); await tester.pumpAndSettle(); - final top1 = tester.getRect(find.byKey(const Key('ranking-cover-1'))); - final top2 = tester.getRect(find.byKey(const Key('ranking-cover-2'))); - final top3 = tester.getRect(find.byKey(const Key('ranking-cover-3'))); - expect(top1.width, greaterThan(top2.width)); - expect(top3.width, closeTo(top2.width, 0.5)); - expect(top2.left, closeTo(8, 0.5)); - expect(top3.right, closeTo(382, 0.5)); - expect(top1.left - top2.right, lessThanOrEqualTo(10)); - expect(top2.width, greaterThan(100)); - expect(top2.left, lessThan(top1.left)); - expect(top3.left, greaterThan(top1.right)); - expect(top1.bottom, closeTo(top2.bottom - 30, 0.5)); - expect(top2.bottom, closeTo(top3.bottom, 0.5)); - expect(find.byKey(const Key('ranking-row-3')), findsNothing); + for (var rank = 1; rank <= 3; rank++) { + expect(find.byKey(Key('ranking-row-${rank - 1}')), findsOneWidget); + expect( + tester.getSize(find.byKey(Key('ranking-row-cover-$rank'))), + const Size(96, 128), + ); + final badge = tester.widget( + find.byKey(Key('ranking-top-$rank')), + ); + final decoration = badge.decoration! as BoxDecoration; + final expected = switch (rank) { + 1 => AppColors.brandRed, + 2 => AppColors.homeCrownGold, + _ => AppColors.bronze, + }; + expect(decoration.color, expected); + } }); - testWidgets('should_list_rank_four_and_below', (tester) async { + testWidgets('should_list_every_rank_as_row', (tester) async { await _pumpTheater(tester, dramas: _rankDramas(5)); await tester.tap(find.byKey(const Key('theater-toolbar-ranking'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('ranking-row-3')), findsOneWidget); - expect(find.byKey(const Key('ranking-row-2')), findsNothing); + for (var index = 0; index < 5; index++) { + expect(find.byKey(Key('ranking-row-$index')), findsOneWidget); + } final rankNumber = tester.widget( - find.byKey(const Key('ranking-top-4')), + find.descendant( + of: find.byKey(const Key('ranking-top-4')), + matching: find.byType(Text), + ), ); expect(rankNumber.data, '4'); - expect(rankNumber.style?.fontSize, 48); - expect(rankNumber.style?.fontWeight, FontWeight.w800); + expect(rankNumber.style?.fontSize, 14); + expect(rankNumber.style?.fontWeight, FontWeight.w700); + final badge = tester.widget( + find.byKey(const Key('ranking-top-4')), + ); + expect( + tester.getSize(find.byKey(const Key('ranking-top-4'))), + const Size(24, 24), + ); + expect( + (badge.decoration! as BoxDecoration).borderRadius, + const BorderRadius.only( + topLeft: Radius.circular(AppDimens.rankingCoverRadius), + bottomRight: Radius.circular(10), + ), + ); expect( tester.getSize(find.byKey(const Key('ranking-row-3'))), - const Size(362, 96), + const Size(362, 128), ); expect( tester.getSize(find.byKey(const Key('ranking-row-cover-4'))), - const Size(60, 80), + const Size(96, 128), ); }); - testWidgets('should_truncate_podium_when_less_than_three', (tester) async { + testWidgets('should_render_single_row_when_only_one_drama', (tester) async { await _pumpTheater(tester, dramas: _rankDramas(1)); await tester.tap(find.byKey(const Key('theater-toolbar-ranking'))); await tester.pumpAndSettle(); @@ -194,14 +227,18 @@ void main() { final list = find.byKey(const Key('ranking-list')); expect(tester.widget(list).physics, AppPullToRefresh.physics); final position = _scrollPosition(tester, const Key('ranking-list')); + // jumpTo 不发滚动通知,滑到底后再拖一下才会触发加载。 position.jumpTo(position.maxScrollExtent); await tester.pump(); + await tester.drag(list, const Offset(0, -60)); + await tester.pump(); expect(find.byKey(const Key('ranking-row-19')), findsOneWidget); expect(find.byKey(const Key('ranking-row-20')), findsNothing); - expect(find.byKey(const Key('ranking-load-more')), findsOneWidget); + // 加载指示出现后会撑高列表,可视区外被 finder 视为 offstage,直接看状态。 + expect(_loadFooter(tester).loading, isTrue); await tester.pump(const Duration(milliseconds: 299)); - expect(find.byKey(const Key('ranking-load-more')), findsOneWidget); + expect(_loadFooter(tester).loading, isTrue); await tester.pump(const Duration(milliseconds: 1)); position.jumpTo(position.maxScrollExtent); @@ -219,11 +256,14 @@ void main() { expect(repository.fetchCalls, beforeRefresh + 1); expect(position.pixels, 0); - position.jumpTo(position.maxScrollExtent); - await tester.pump(); - expect(find.byKey(const Key('ranking-load-more')), findsOneWidget); - expect(find.byKey(const Key('ranking-no-more')), findsNothing); + // 刷新重置回 20 项后,滑到底再触发一次加载,应恢复 40 项。 + final refreshed = _scrollPosition(tester, const Key('ranking-list')); + refreshed.jumpTo(refreshed.maxScrollExtent); await tester.pumpAndSettle(); + refreshed.jumpTo(refreshed.maxScrollExtent); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('ranking-row-39')), findsOneWidget); + expect(find.byKey(const Key('ranking-no-more')), findsNothing); }); testWidgets('should_page_new_releases_and_reset_after_refresh', ( @@ -311,10 +351,9 @@ Future _pumpTheater( ), ); await tester.pumpAndSettle(); - Navigator.of(tester.element(find.byKey(const Key('home-tabs')))) - .push( - MaterialPageRoute(builder: (_) => const DiscoverPage()), - ); + Navigator.of( + tester.element(find.byKey(const Key('home-tabs'))), + ).push(MaterialPageRoute(builder: (_) => const DiscoverPage())); await tester.pumpAndSettle(); } @@ -326,6 +365,15 @@ ScrollPosition _scrollPosition(WidgetTester tester, Key listKey) { return tester.state(scrollable.first).position; } +PagedLoadFooter _loadFooter(WidgetTester tester) { + return tester.widget( + find.descendant( + of: find.byKey(const Key('ranking-page'), skipOffstage: false), + matching: find.byType(PagedLoadFooter, skipOffstage: false), + ), + ); +} + class _CountingCatalogRepository extends FakeCatalogRepository { _CountingCatalogRepository({super.dramas}); diff --git a/test/home/home_ranking_dramas_test.dart b/test/home/home_ranking_dramas_test.dart new file mode 100644 index 0000000..c0a774c --- /dev/null +++ b/test/home/home_ranking_dramas_test.dart @@ -0,0 +1,86 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/category_ranking.dart'; +import 'package:fruitshorts/core/data/models/content_category.dart'; +import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/models/home_feed.dart'; +import 'package:fruitshorts/core/data/repositories/category_repository.dart'; +import 'package:fruitshorts/core/data/services/category_controller.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/home/widgets/home_ranking_dramas.dart'; +import 'package:provider/provider.dart'; + +void main() { + testWidgets('should_render_uniform_rows_in_server_rank_order', ( + tester, + ) async { + final controller = CategoryController(_EmptyCategoryRepository()); + controller.rankingSlotOf('ranking').ranking = CategoryRanking( + id: 'ranking', + code: 'RECOMMEND_RANKING', + revision: '4', + entries: [ + for (var rank = 1; rank <= 4; rank++) + CategoryRankingEntry(rank: rank, drama: _drama('$rank')), + ], + ); + Drama? opened; + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: controller, + child: MaterialApp( + theme: AppTheme.dark(), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: HomeRankingDramas( + categoryId: 'ranking', + onOpenDrama: (drama) => opened = drama, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + for (var rank = 1; rank <= 4; rank++) { + expect(find.byKey(Key('ranking-top-$rank')), findsOneWidget); + expect(find.byKey(Key('ranking-row-${rank - 1}')), findsOneWidget); + } + expect(find.byKey(const Key('home-ranking-top-1')), findsNothing); + final surface = tester.widget( + find.byKey(const Key('home-ranking-surface')), + ); + final gradient = + (surface.decoration as BoxDecoration).gradient! as LinearGradient; + expect(gradient.colors.first, AppColors.followingGlow); + expect( + tester.getSize(find.byKey(const Key('ranking-row-cover-1'))), + const Size(96, 128), + ); + + await tester.tap(find.byKey(const Key('ranking-top-2'))); + expect(opened?.id, '2'); + }); +} + +Drama _drama(String id) => Drama( + id: id, + title: 'Rank $id', + synopsis: '', + posterUrl: '', + viewCount: int.parse(id) * 10, +); + +class _EmptyCategoryRepository implements CategoryRepository { + @override + Future> fetchCategories() async => const []; + + @override + Future fetchDiscoverFeed() async => const HomeFeed(); +} diff --git a/test/ranking/ranking_page_test.dart b/test/ranking/ranking_page_test.dart new file mode 100644 index 0000000..534297e --- /dev/null +++ b/test/ranking/ranking_page_test.dart @@ -0,0 +1,126 @@ +// 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/data/models/feed_page.dart'; +import 'package:fruitshorts/core/data/models/home_feed.dart'; +import 'package:fruitshorts/core/data/repositories/catalog_repository.dart'; +import 'package:fruitshorts/core/data/services/catalog_controller.dart'; +import 'package:fruitshorts/core/theme/app_theme.dart'; +import 'package:fruitshorts/l10n/app_localizations.dart'; +import 'package:fruitshorts/pages/ranking/ranking_page.dart'; +import 'package:provider/provider.dart'; + +void main() { + testWidgets('should_render_rank_badges_on_covers_for_all_entries', ( + tester, + ) async { + await _pumpPage(tester, [ + _drama('a', viewCount: 400), + _drama('b', viewCount: 300), + _drama('c', viewCount: 200), + _drama('d', viewCount: 100), + ]); + + expect(find.byKey(const Key('ranking-top-1')), findsOneWidget); + expect(find.byKey(const Key('ranking-top-2')), findsOneWidget); + expect(find.byKey(const Key('ranking-top-3')), findsOneWidget); + expect(find.byKey(const Key('ranking-top-4')), findsOneWidget); + expect(find.byKey(const Key('ranking-row-cover-1')), findsOneWidget); + expect(find.byKey(const Key('ranking-title-1')), findsOneWidget); + }); + + testWidgets('should_hide_optional_fields_when_drama_has_no_data', ( + tester, + ) async { + await _pumpPage(tester, [_drama('solo')]); + + expect(find.byKey(const Key('ranking-tag-1')), findsNothing); + expect(find.byIcon(Icons.local_fire_department_rounded), findsNothing); + }); + + testWidgets('should_show_optional_fields_when_present', (tester) async { + await _pumpPage(tester, [ + _drama( + 'rich', + viewCount: 15340000, + likeCount: 550, + tag: '新剧', + genre: '剧情', + synopsis: '以拟人化视角,重温长征岁月。', + ), + ]); + + expect(find.byKey(const Key('ranking-tag-1')), findsOneWidget); + expect(find.text('新剧'), findsOneWidget); + expect(find.text('剧情'), findsOneWidget); + expect(find.text('以拟人化视角,重温长征岁月。'), findsOneWidget); + expect(find.text('1534w heat · 550 likes'), findsOneWidget); + final synopsis = tester.widget(find.text('以拟人化视角,重温长征岁月。')); + expect(synopsis.maxLines, 3); + expect(synopsis.overflow, TextOverflow.ellipsis); + final tag = tester.widget(find.byKey(const Key('ranking-tag-1'))); + expect(tag.style?.color, isNotNull); + final synopsisRect = tester.getRect(find.text('以拟人化视角,重温长征岁月。')); + final tagRect = tester.getRect(find.byKey(const Key('ranking-tag-1'))); + expect(tagRect.top - synopsisRect.bottom, lessThan(8)); + }); +} + +Future _pumpPage(WidgetTester tester, List dramas) async { + final controller = CatalogController(_EmptyCatalogRepository()); + controller.feed = HomeFeed( + sections: [FeedSection(code: 'hot', title: 'Hot', dramas: dramas)], + ); + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: controller, + child: MaterialApp( + theme: AppTheme.dark(), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const RankingPage(), + ), + ), + ); + await tester.pumpAndSettle(); +} + +Drama _drama( + String id, { + int viewCount = 0, + int? likeCount, + String tag = '', + String genre = '', + String synopsis = '', +}) => Drama( + id: id, + title: 'Drama $id', + synopsis: synopsis, + posterUrl: '', + tag: tag, + genre: genre, + viewCount: viewCount, + likeCount: likeCount, +); + +class _EmptyCatalogRepository implements CatalogRepository { + @override + Future fetchHomeFeed() async => const HomeFeed(); + + @override + Future fetchDiscoverFeed() async => const HomeFeed(); + + @override + Future fetchFeed({ + List types = const [], + String? categoryId, + String? cursor, + int size = 10, + }) async => const FeedPage(); + + @override + Future fetchDrama(String dramaId) async => + Drama(id: dramaId, title: '', synopsis: '', posterUrl: ''); +} diff --git a/test/recommend/recommend_discover_empty_state_test.dart b/test/recommend/recommend_discover_empty_state_test.dart new file mode 100644 index 0000000..190d6f6 --- /dev/null +++ b/test/recommend/recommend_discover_empty_state_test.dart @@ -0,0 +1,138 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/category_ranking.dart'; +import 'package:fruitshorts/core/data/models/content_category.dart'; +import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/models/page_result.dart'; +import 'package:fruitshorts/core/data/services/category_controller.dart'; +import 'package:fruitshorts/l10n/app_localizations.dart'; +import 'package:fruitshorts/pages/recommend/recommend_discover_page.dart'; +import 'package:provider/provider.dart'; + +import '../support/fake_catalog.dart'; +import '../support/fake_category.dart'; + +void main() { + testWidgets('should_center_empty_state_when_category_has_no_dramas', ( + tester, + ) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final controller = CategoryController( + FakeCategoryRepository(catalog: FakeCatalogRepository()), + ); + controller.categories = const [ContentCategory(id: '7', name: 'Romance')]; + controller.dramaPageLoader = + (categoryId, {pageIndex = 1, pageSize = 20}) async => + const PageResult(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + locale: const Locale('zh'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: ChangeNotifierProvider.value( + value: controller, + child: const RecommendDiscoverPage(active: true, onSearch: _noop), + ), + ), + ); + await tester.pump(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 1)); + + expect(find.text('暂无剧目'), findsOneWidget); + expect(find.text('暂无剧目。'), findsNothing); + + final content = tester.getRect( + find.byKey(const Key('empty-state-content')), + ); + final icon = tester.getRect(find.byKey(const Key('empty-state-icon'))); + final title = tester.getRect(find.byKey(const Key('empty-state-title'))); + final viewport = tester.view.physicalSize / tester.view.devicePixelRatio; + expect(content.center.dx, closeTo(viewport.width / 2, 1.0)); + expect(content.center.dy, closeTo(viewport.height / 2, 1.0)); + expect(icon.center.dx, closeTo(title.center.dx, 0.1)); + expect(title.top - icon.bottom, closeTo(12, 0.1)); + }); + + testWidgets('should_render_ranking_layout_when_category_is_ranking', ( + tester, + ) async { + final controller = CategoryController( + FakeCategoryRepository(catalog: FakeCatalogRepository()), + ); + controller.categories = const [ + ContentCategory( + id: 'ranking', + name: '推荐榜', + layoutType: ContentCategory.rankingLayout, + ), + ]; + var dramaLoads = 0; + var rankingLoads = 0; + controller.dramaPageLoader = + (categoryId, {pageIndex = 1, pageSize = 20}) async { + dramaLoads++; + return const PageResult(); + }; + controller.rankingLoader = (categoryId) async { + rankingLoads++; + return CategoryRanking( + id: categoryId, + code: 'RECOMMEND_RANKING', + revision: '2', + entries: [ + for (var rank = 1; rank <= 4; rank++) + CategoryRankingEntry(rank: rank, drama: _drama('$rank')), + ], + ); + }; + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + locale: const Locale('zh'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: ChangeNotifierProvider.value( + value: controller, + child: const RecommendDiscoverPage(active: true, onSearch: _noop), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(rankingLoads, 1); + expect(dramaLoads, 0); + expect(find.byKey(const Key('ranking-top-1')), findsOneWidget); + expect(find.byKey(const Key('ranking-row-3')), findsOneWidget); + final navUnderline = tester.getRect( + find.byKey(const Key('recommend-tab-ranking-underline')), + ); + final firstRow = tester.getRect(find.byKey(const Key('ranking-row-0'))); + final gap = firstRow.top - navUnderline.bottom; + expect(gap, greaterThan(10)); + expect(gap, lessThan(26)); + expect( + find.byKey(const Key('recommend-category-grid-ranking')), + findsNothing, + ); + }); +} + +void _noop() {} + +Drama _drama(String id) => Drama( + id: id, + title: 'Rank $id', + synopsis: '', + posterUrl: '', + viewCount: int.parse(id) * 10, +);