From f1b541895460cb5426292fa1df5e578f550cc5d0 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 17:23:22 +0800 Subject: [PATCH] =?UTF-8?q?feat(recommend):=20=E6=96=B0=E5=A2=9E=E5=88=86?= =?UTF-8?q?=E5=8C=BA=E6=B5=81=E5=85=A8=E9=83=A8=E7=9F=AD=E5=89=A7=E4=B8=8E?= =?UTF-8?q?=E5=89=A7=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data/services/home_feed_controller.dart | 2 +- lib/l10n/app_en.arb | 18 +- lib/l10n/app_id.arb | 18 +- lib/l10n/app_localizations.dart | 96 ++++ lib/l10n/app_localizations_en.dart | 48 ++ lib/l10n/app_localizations_id.dart | 50 ++ lib/l10n/app_localizations_zh.dart | 48 ++ lib/l10n/app_zh.arb | 18 +- lib/pages/home/home_view.dart | 136 +----- lib/pages/ranking/ranking_page.dart | 116 ++--- lib/pages/recommend/recommend_page.dart | 199 ++++++++ .../widgets/all_dramas_filter_grid.dart | 292 ++++++++++++ .../widgets/recommend_section_card.dart | 116 +++++ .../widgets/recommend_sections_view.dart | 209 +++++++++ test/discover/home_all_dramas_grid_test.dart | 72 --- test/home/home_grid_layout_test.dart | 2 +- test/home/home_nav_ui_test.dart | 2 +- test/home/home_preview_layout_test.dart | 19 +- test/home/k28_a_navigation_test.dart | 430 +++--------------- test/player/player_fullscreen_test.dart | 81 +--- test/recommend/recommend_sections_test.dart | 136 ++++++ test/widget_test.dart | 40 +- 22 files changed, 1426 insertions(+), 722 deletions(-) create mode 100644 lib/pages/recommend/recommend_page.dart create mode 100644 lib/pages/recommend/widgets/all_dramas_filter_grid.dart create mode 100644 lib/pages/recommend/widgets/recommend_section_card.dart create mode 100644 lib/pages/recommend/widgets/recommend_sections_view.dart create mode 100644 test/recommend/recommend_sections_test.dart diff --git a/lib/core/data/services/home_feed_controller.dart b/lib/core/data/services/home_feed_controller.dart index c85d681..7326983 100644 --- a/lib/core/data/services/home_feed_controller.dart +++ b/lib/core/data/services/home_feed_controller.dart @@ -18,7 +18,7 @@ class HomeFeedController extends ChangeNotifier { channel: _ChannelFeedState(), }; - static const int pageSize = 10; + static const int pageSize = 20; static const Duration defaultRequestTimeout = Duration(seconds: 10); final CatalogRepository _repository; diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index b4899d1..7d1c915 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -714,5 +714,21 @@ "homeBadgeNew": "New", "homeBadgeAnime": "Anime", "homeGridFilterEmpty": "No dramas match these filters", - "homeGridClearFilter": "Clear filters" + "homeGridClearFilter": "Clear filters", + "recommendTopTab": "Recommend", + "playlistTopTab": "Playlists", + "allDramasTopTab": "All Dramas", + "weeklyHitsTitle": "This Week's Hits", + "weeklyHitsSubtitle": "Binge the hottest stories of the week!", + "weeklyNewTitle": "New This Week", + "weeklyNewSubtitle": "Fresh releases to start your next binge", + "costumeAwardsTitle": "Costume Drama Awards", + "costumeAwardsSubtitle": "Top-rated picks every costume fan needs", + "localFilter": "Local", + "asiaFilter": "Asia", + "westernFilter": "Western", + "subtitlesOnlyFilter": "Subtitles", + "dubbedFilter": "Dubbed", + "reversalFilter": "Comeback", + "modernFilter": "Modern" } diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 38bc11f..d1fdecb 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -671,5 +671,21 @@ "homeBadgeNew": "Baru", "homeBadgeAnime": "Anime", "homeGridFilterEmpty": "Tidak ada drama yang cocok dengan filter", - "homeGridClearFilter": "Hapus filter" + "homeGridClearFilter": "Hapus filter", + "recommendTopTab": "Rekomendasi", + "playlistTopTab": "Daftar Putar", + "allDramasTopTab": "Semua Drama", + "weeklyHitsTitle": "Hits Minggu Ini", + "weeklyHitsSubtitle": "Drama terpopuler minggu ini, tonton sekaligus!", + "weeklyNewTitle": "Drama Baru Minggu Ini", + "weeklyNewSubtitle": "Rilisan baru untuk memulai maraton berikutnya", + "costumeAwardsTitle": "Drama Kostum Terbaik", + "costumeAwardsSubtitle": "Pilihan terbaik yang wajib ditonton", + "localFilter": "Lokal", + "asiaFilter": "Asia", + "westernFilter": "Barat", + "subtitlesOnlyFilter": "Subtitle", + "dubbedFilter": "Dubbing", + "reversalFilter": "Balas Dendam", + "modernFilter": "Modern" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 776d1c0..137e51f 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -3099,6 +3099,102 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Clear filters'** String get homeGridClearFilter; + + /// No description provided for @recommendTopTab. + /// + /// In en, this message translates to: + /// **'Recommend'** + String get recommendTopTab; + + /// No description provided for @playlistTopTab. + /// + /// In en, this message translates to: + /// **'Playlists'** + String get playlistTopTab; + + /// No description provided for @allDramasTopTab. + /// + /// In en, this message translates to: + /// **'All Dramas'** + String get allDramasTopTab; + + /// No description provided for @weeklyHitsTitle. + /// + /// In en, this message translates to: + /// **'This Week\'s Hits'** + String get weeklyHitsTitle; + + /// No description provided for @weeklyHitsSubtitle. + /// + /// In en, this message translates to: + /// **'Binge the hottest stories of the week!'** + String get weeklyHitsSubtitle; + + /// No description provided for @weeklyNewTitle. + /// + /// In en, this message translates to: + /// **'New This Week'** + String get weeklyNewTitle; + + /// No description provided for @weeklyNewSubtitle. + /// + /// In en, this message translates to: + /// **'Fresh releases to start your next binge'** + String get weeklyNewSubtitle; + + /// No description provided for @costumeAwardsTitle. + /// + /// In en, this message translates to: + /// **'Costume Drama Awards'** + String get costumeAwardsTitle; + + /// No description provided for @costumeAwardsSubtitle. + /// + /// In en, this message translates to: + /// **'Top-rated picks every costume fan needs'** + String get costumeAwardsSubtitle; + + /// No description provided for @localFilter. + /// + /// In en, this message translates to: + /// **'Local'** + String get localFilter; + + /// No description provided for @asiaFilter. + /// + /// In en, this message translates to: + /// **'Asia'** + String get asiaFilter; + + /// No description provided for @westernFilter. + /// + /// In en, this message translates to: + /// **'Western'** + String get westernFilter; + + /// No description provided for @subtitlesOnlyFilter. + /// + /// In en, this message translates to: + /// **'Subtitles'** + String get subtitlesOnlyFilter; + + /// No description provided for @dubbedFilter. + /// + /// In en, this message translates to: + /// **'Dubbed'** + String get dubbedFilter; + + /// No description provided for @reversalFilter. + /// + /// In en, this message translates to: + /// **'Comeback'** + String get reversalFilter; + + /// No description provided for @modernFilter. + /// + /// In en, this message translates to: + /// **'Modern'** + String get modernFilter; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 800a55f..1e95bd6 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1616,4 +1616,52 @@ class AppLocalizationsEn extends AppLocalizations { @override String get homeGridClearFilter => 'Clear filters'; + + @override + String get recommendTopTab => 'Recommend'; + + @override + String get playlistTopTab => 'Playlists'; + + @override + String get allDramasTopTab => 'All Dramas'; + + @override + String get weeklyHitsTitle => 'This Week\'s Hits'; + + @override + String get weeklyHitsSubtitle => 'Binge the hottest stories of the week!'; + + @override + String get weeklyNewTitle => 'New This Week'; + + @override + String get weeklyNewSubtitle => 'Fresh releases to start your next binge'; + + @override + String get costumeAwardsTitle => 'Costume Drama Awards'; + + @override + String get costumeAwardsSubtitle => 'Top-rated picks every costume fan needs'; + + @override + String get localFilter => 'Local'; + + @override + String get asiaFilter => 'Asia'; + + @override + String get westernFilter => 'Western'; + + @override + String get subtitlesOnlyFilter => 'Subtitles'; + + @override + String get dubbedFilter => 'Dubbed'; + + @override + String get reversalFilter => 'Comeback'; + + @override + String get modernFilter => 'Modern'; } diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 22e2caa..4d6038e 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -1616,4 +1616,54 @@ class AppLocalizationsId extends AppLocalizations { @override String get homeGridClearFilter => 'Hapus filter'; + + @override + String get recommendTopTab => 'Rekomendasi'; + + @override + String get playlistTopTab => 'Daftar Putar'; + + @override + String get allDramasTopTab => 'Semua Drama'; + + @override + String get weeklyHitsTitle => 'Hits Minggu Ini'; + + @override + String get weeklyHitsSubtitle => + 'Drama terpopuler minggu ini, tonton sekaligus!'; + + @override + String get weeklyNewTitle => 'Drama Baru Minggu Ini'; + + @override + String get weeklyNewSubtitle => + 'Rilisan baru untuk memulai maraton berikutnya'; + + @override + String get costumeAwardsTitle => 'Drama Kostum Terbaik'; + + @override + String get costumeAwardsSubtitle => 'Pilihan terbaik yang wajib ditonton'; + + @override + String get localFilter => 'Lokal'; + + @override + String get asiaFilter => 'Asia'; + + @override + String get westernFilter => 'Barat'; + + @override + String get subtitlesOnlyFilter => 'Subtitle'; + + @override + String get dubbedFilter => 'Dubbing'; + + @override + String get reversalFilter => 'Balas Dendam'; + + @override + String get modernFilter => 'Modern'; } diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 299c1ff..7d5ba02 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -1592,4 +1592,52 @@ class AppLocalizationsZh extends AppLocalizations { @override String get homeGridClearFilter => '清除筛选'; + + @override + String get recommendTopTab => '推荐'; + + @override + String get playlistTopTab => '剧单'; + + @override + String get allDramasTopTab => '全部短剧'; + + @override + String get weeklyHitsTitle => '本週爆款推薦'; + + @override + String get weeklyHitsSubtitle => '本週話題熱劇,一次追完!'; + + @override + String get weeklyNewTitle => '本週精選新劇'; + + @override + String get weeklyNewSubtitle => '新劇來襲!追劇節奏啟動'; + + @override + String get costumeAwardsTitle => '古裝神劇大賞'; + + @override + String get costumeAwardsSubtitle => '口碑爆棚!古裝控必收片單'; + + @override + String get localFilter => '本地'; + + @override + String get asiaFilter => '亚洲'; + + @override + String get westernFilter => '欧美'; + + @override + String get subtitlesOnlyFilter => '仅字幕'; + + @override + String get dubbedFilter => '配音'; + + @override + String get reversalFilter => '逆袭'; + + @override + String get modernFilter => '现代'; } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index e8c2a0f..0385554 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -633,5 +633,21 @@ "homeBadgeNew": "新剧", "homeBadgeAnime": "漫剧", "homeGridFilterEmpty": "没有符合筛选条件的短剧", - "homeGridClearFilter": "清除筛选" + "homeGridClearFilter": "清除筛选", + "recommendTopTab": "推荐", + "playlistTopTab": "剧单", + "allDramasTopTab": "全部短剧", + "weeklyHitsTitle": "本週爆款推薦", + "weeklyHitsSubtitle": "本週話題熱劇,一次追完!", + "weeklyNewTitle": "本週精選新劇", + "weeklyNewSubtitle": "新劇來襲!追劇節奏啟動", + "costumeAwardsTitle": "古裝神劇大賞", + "costumeAwardsSubtitle": "口碑爆棚!古裝控必收片單", + "localFilter": "本地", + "asiaFilter": "亚洲", + "westernFilter": "欧美", + "subtitlesOnlyFilter": "仅字幕", + "dubbedFilter": "配音", + "reversalFilter": "逆袭", + "modernFilter": "现代" } diff --git a/lib/pages/home/home_view.dart b/lib/pages/home/home_view.dart index d153f09..02e860f 100644 --- a/lib/pages/home/home_view.dart +++ b/lib/pages/home/home_view.dart @@ -8,7 +8,6 @@ import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../../core/config/app_config.dart'; -import '../../core/data/models/drama.dart'; import '../../core/data/services/home_feed_controller.dart'; import '../../core/data/services/catalog_controller.dart'; import '../../core/data/services/catalog_retry.dart'; @@ -27,13 +26,11 @@ import '../favorites/favorites_page.dart'; import '../filter/filter_page.dart'; import '../mine/mine_types.dart'; import '../new_releases/new_releases_page.dart'; -import '../player/widgets/home_feed_header.dart'; -import '../player/player_view.dart'; import '../ranking/ranking_page.dart'; +import '../recommend/recommend_page.dart'; import '../mine/mine_view.dart'; import '../store/vip_page.dart'; import '../wallet/wallet_page.dart'; -import 'home_feed_channel.dart'; import 'home_binding.dart'; import 'home_logic.dart'; import 'widgets/home_feed_grid.dart'; @@ -209,15 +206,11 @@ class _HomeTabs extends StatefulWidget { class _HomeTabsState extends State<_HomeTabs> { late final HomeLogic logic; - List? _visibleDramas; - HomeFeedChannel? _visibleChannel; @override void initState() { super.initState(); logic = HomeBinding().create(widget.feedController.dramas); - _visibleDramas = widget.feedController.dramas; - _visibleChannel = widget.feedController.channel; widget.feedController.addListener(_onFeedChanged); } @@ -297,132 +290,13 @@ class _HomeTabsState extends State<_HomeTabs> { } Widget _recommend(BuildContext context) { - final currentDramas = widget.feedController.dramas; - final currentChannel = widget.feedController.channel; - if (currentDramas.isNotEmpty) { - _visibleDramas = currentDramas; - _visibleChannel = currentChannel; - } - if (currentDramas.isEmpty && !widget.feedController.loadingFirstPage) { - return _feedChannelPlaceholder(context); - } - final visibleDramas = _visibleDramas; - final visibleChannel = _visibleChannel; - final switching = - currentDramas.isEmpty && widget.feedController.loadingFirstPage; - if (visibleDramas == null || - visibleDramas.isEmpty || - visibleChannel == null) { - return _feedChannelPlaceholder(context); - } - final playerDramas = currentDramas.isEmpty ? visibleDramas : currentDramas; - final playerChannel = currentDramas.isEmpty - ? visibleChannel - : currentChannel; - return Stack( - fit: StackFit.expand, - children: [ - PlayerPage( - homeMode: true, - embedded: true, - active: logic.tab == 1, - drama: playerDramas.first, - initialIndex: 0, - feedDramas: playerDramas, - feedChannel: playerChannel, - headerChannel: currentChannel, - onOpenFullPlayer: (drama, episode) => - logic.playDrama(context, drama, episodeId: episode.id), - onRefreshFeed: widget.feedController.refreshCurrent, - onLoadMore: widget.feedController.loadMore, - onChannelSelected: _selectFeedChannel, - onDramaChanged: logic.onDramaChanged, - onImmersiveChanged: logic.onImmersiveChanged, - onSearch: () => _openSearch(context), - onDiscover: () => logic.select(1), - onMy: () => logic.select(3), - ), - if (switching) - Positioned.fill( - child: IgnorePointer( - key: const Key('home-feed-channel-loading'), - child: ColoredBox( - color: AppColors.controlSoftScrim, - child: Center( - child: SizedBox.square( - dimension: 24, - child: CircularProgressIndicator( - strokeWidth: 2.4, - color: AppColors.muted, - ), - ), - ), - ), - ), - ), - ], + return RecommendPage( + feedController: widget.feedController, + onSearch: () => _openSearch(context), + onOpenFilter: () => _pushPage(context, const FilterPage()), ); } - Widget _feedChannelPlaceholder(BuildContext context) { - final l10n = AppLocalizations.of(context)!; - return Stack( - fit: StackFit.expand, - children: [ - const ColoredBox(color: AppColors.mediaBlack), - SafeArea( - bottom: false, - child: Column( - children: [ - HomeFeedHeader( - l10n: l10n, - channel: widget.feedController.channel, - onChannelSelected: _selectFeedChannel, - onRefresh: () => _refreshPlaceholder(context), - onSearch: () => _openSearch(context), - onMore: () {}, - ), - Expanded( - child: widget.feedController.loadingFirstPage - ? const Center( - child: SizedBox.square( - dimension: 24, - child: CircularProgressIndicator( - key: Key('home-feed-channel-loading'), - strokeWidth: 2.4, - color: AppColors.muted, - ), - ), - ) - : EmptyState( - icon: Icons.movie_outlined, - title: l10n.noStories, - actionLabel: l10n.retry, - onAction: () => - unawaited(widget.feedController.refreshCurrent()), - ), - ), - ], - ), - ), - ], - ); - } - - Future _refreshPlaceholder(BuildContext context) async { - final ok = await widget.feedController.refreshCurrent(); - if (!ok && context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(AppLocalizations.of(context)!.catalogError)), - ); - } - return ok; - } - - void _selectFeedChannel(HomeFeedChannel channel) { - unawaited(widget.feedController.selectChannel(channel)); - } - void _openSearch(BuildContext context) { Navigator.of(context).push( MaterialPageRoute(builder: (_) => const DiscoverPage(focusSearch: true)), diff --git a/lib/pages/ranking/ranking_page.dart b/lib/pages/ranking/ranking_page.dart index 24c34a4..615e9b3 100644 --- a/lib/pages/ranking/ranking_page.dart +++ b/lib/pages/ranking/ranking_page.dart @@ -34,7 +34,9 @@ const Color _bronzeEnd = AppColors.bronzeDeep; /// 剧场榜单页:前三名横排领奖台,第 4 名起纵向列表。 class RankingPage extends StatefulWidget { - const RankingPage({super.key}); + const RankingPage({super.key, this.embedded = false}); + + final bool embedded; @override State createState() => _RankingPageState(); @@ -109,63 +111,71 @@ class _RankingPageState extends State { final top = pager.visibleItems; final podium = top.take(3).toList(); final rest = top.skip(3).toList(); + final content = NotificationListener( + onNotification: _onScroll, + child: AppPullToRefresh( + key: const Key('ranking-refresh'), + onRefresh: _refresh, + child: ListView( + key: const Key('ranking-list'), + controller: _scroll, + physics: AppPullToRefresh.physics, + padding: const EdgeInsets.only(top: 4, bottom: 24), + children: top.isEmpty + ? [ + SizedBox( + height: 420, + child: EmptyState( + icon: Icons.leaderboard_outlined, + title: l10n.noStories, + ), + ), + ] + : [ + if (podium.isNotEmpty) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 14), + child: _Podium(podium: podium, l10n: l10n), + ), + if (rest.isNotEmpty) ...[ + const SizedBox(height: 16), + for (var index = 0; index < rest.length; index++) ...[ + if (index > 0) const SizedBox(height: _rowGap), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 14), + child: _RankingRow( + index: index + 3, + drama: rest[index], + l10n: l10n, + ), + ), + ], + ], + PagedLoadFooter( + loading: pager.loading, + hasMore: pager.hasMore, + hasItems: pager.items.isNotEmpty, + loadMoreKey: const Key('ranking-load-more'), + noMoreKey: const Key('ranking-no-more'), + noMoreLabel: l10n.noMore, + ), + ], + ), + ), + ); + if (widget.embedded) { + return ColoredBox( + key: const Key('ranking-page'), + color: AppColors.appBackground, + child: SafeArea(bottom: false, child: content), + ); + } return PlainPageScaffold( pageKey: const Key('ranking-page'), backKey: const Key('ranking-back'), title: l10n.topCharts, subtitle: l10n.rankingSubtitle, - child: NotificationListener( - onNotification: _onScroll, - child: AppPullToRefresh( - key: const Key('ranking-refresh'), - onRefresh: _refresh, - child: ListView( - key: const Key('ranking-list'), - controller: _scroll, - physics: AppPullToRefresh.physics, - padding: const EdgeInsets.only(top: 4, bottom: 24), - children: top.isEmpty - ? [ - SizedBox( - height: 420, - child: EmptyState( - icon: Icons.leaderboard_outlined, - title: l10n.noStories, - ), - ), - ] - : [ - if (podium.isNotEmpty) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 14), - child: _Podium(podium: podium, l10n: l10n), - ), - if (rest.isNotEmpty) ...[ - const SizedBox(height: 16), - for (var index = 0; index < rest.length; index++) ...[ - if (index > 0) const SizedBox(height: _rowGap), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 14), - child: _RankingRow( - index: index + 3, - drama: rest[index], - l10n: l10n, - ), - ), - ], - ], - PagedLoadFooter( - loading: pager.loading, - hasMore: pager.hasMore, - hasItems: pager.items.isNotEmpty, - loadMoreKey: const Key('ranking-load-more'), - noMoreKey: const Key('ranking-no-more'), - noMoreLabel: l10n.noMore, - ), - ], - ), - ), - ), + child: content, ); } } diff --git a/lib/pages/recommend/recommend_page.dart b/lib/pages/recommend/recommend_page.dart new file mode 100644 index 0000000..8f3aad9 --- /dev/null +++ b/lib/pages/recommend/recommend_page.dart @@ -0,0 +1,199 @@ +// Author: fruit.com.cn + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../core/data/models/drama.dart'; +import '../../core/data/services/catalog_controller.dart'; +import '../../core/data/services/category_controller.dart'; +import '../../core/data/services/download_controller.dart'; +import '../../core/data/services/home_feed_controller.dart'; +import '../../core/data/services/wallet_controller.dart'; +import '../../core/navigation/fade_page_route.dart'; +import '../../core/theme/app_colors.dart'; +import '../../core/theme/app_dimens.dart'; +import '../../l10n/app_localizations.dart'; +import '../player/player_view.dart'; +import '../ranking/ranking_page.dart'; +import 'widgets/all_dramas_filter_grid.dart'; +import 'widgets/recommend_sections_view.dart'; + +enum RecommendInnerTab { feed, playlists, allDramas } + +class RecommendPage extends StatefulWidget { + const RecommendPage({ + super.key, + required this.feedController, + required this.onSearch, + required this.onOpenFilter, + }); + + final HomeFeedController feedController; + final VoidCallback onSearch; + final VoidCallback onOpenFilter; + + @override + State createState() => _RecommendPageState(); +} + +class _RecommendPageState extends State { + RecommendInnerTab _tab = RecommendInnerTab.feed; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final categories = context.watch(); + return ColoredBox( + key: const Key('recommend-page'), + color: AppColors.appBackground, + child: SafeArea( + bottom: false, + child: Column( + children: [ + _header(l10n), + Expanded( + child: IndexedStack( + index: _tab.index, + children: [ + RecommendSectionsView( + feedController: widget.feedController, + categories: categories, + onOpenDrama: _openDrama, + onMore: _handleMore, + ), + const RankingPage(embedded: true), + AllDramasFilterGrid( + feedController: widget.feedController, + onOpenDrama: _openDrama, + onOpenFilter: widget.onOpenFilter, + ), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _header(AppLocalizations l10n) { + final items = [ + (RecommendInnerTab.feed, l10n.recommendTopTab, 'recommend'), + (RecommendInnerTab.playlists, l10n.playlistTopTab, 'playlist'), + (RecommendInnerTab.allDramas, l10n.allDramasTopTab, 'all'), + ]; + return SizedBox( + key: const Key('recommend-top-nav'), + height: 56, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppDimens.pageHorizontal, + ), + child: Row( + children: [ + Expanded( + child: Row( + children: [ + for (final item in items) + Expanded( + child: _tabButton( + key: Key('recommend-tab-${item.$3}'), + label: item.$2, + selected: _tab == item.$1, + onTap: () => setState(() => _tab = item.$1), + ), + ), + ], + ), + ), + const SizedBox(width: AppDimens.spaceSm), + IconButton( + key: const Key('recommend-search'), + tooltip: l10n.search, + onPressed: widget.onSearch, + iconSize: 24, + color: AppColors.textPrimary, + icon: const Icon(Icons.search_rounded), + ), + ], + ), + ), + ); + } + + Widget _tabButton({ + required Key key, + required String label, + required bool selected, + required VoidCallback onTap, + }) { + return InkWell( + key: key, + onTap: onTap, + child: Padding( + padding: const EdgeInsets.only(right: AppDimens.spaceSm), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: selected + ? AppColors.textPrimary + : AppColors.textSecondary, + fontSize: AppDimens.listTitle, + fontWeight: selected ? FontWeight.w700 : FontWeight.w500, + ), + ), + const SizedBox(height: 3), + Container( + width: selected ? 20 : 0, + height: 2, + decoration: BoxDecoration( + color: AppColors.textPrimary, + borderRadius: BorderRadius.circular(AppDimens.pillRadius), + ), + ), + ], + ), + ), + ); + } + + void _handleMore(RecommendSectionKind section) { + setState(() { + _tab = section == RecommendSectionKind.hits + ? RecommendInnerTab.playlists + : RecommendInnerTab.allDramas; + }); + } + + Future _openDrama(Drama drama) async { + final catalog = context.read(); + final wallet = context.read(); + final downloads = context.read(); + var playable = drama; + try { + playable = await catalog.requireDetail(drama); + } catch (_) { + // 卡片预览仍可直接进入播放器。 + } + if (!mounted) return; + await Navigator.of(context).push( + FadePageRoute( + builder: (_) => PlayerPage( + drama: playable, + initialIndex: 0, + homeMode: true, + feedDramas: [playable], + wallet: wallet, + downloads: downloads, + ), + ), + ); + } +} diff --git a/lib/pages/recommend/widgets/all_dramas_filter_grid.dart b/lib/pages/recommend/widgets/all_dramas_filter_grid.dart new file mode 100644 index 0000000..554dfdd --- /dev/null +++ b/lib/pages/recommend/widgets/all_dramas_filter_grid.dart @@ -0,0 +1,292 @@ +// Author: fruit.com.cn + +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../../../core/data/models/drama.dart'; +import '../../../core/data/services/home_feed_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 '../../home/widgets/home_drama_classification.dart'; +import '../../home/widgets/home_feed_card.dart'; + +const double _loadMoreThreshold = 240; +const List _modernTerms = ['现代', 'modern', 'moderno']; +const List _reversalTerms = ['逆袭', 'comeback', 'balas']; + +class AllDramasFilterGrid extends StatefulWidget { + const AllDramasFilterGrid({ + super.key, + required this.feedController, + required this.onOpenDrama, + required this.onOpenFilter, + }); + + final HomeFeedController feedController; + final ValueChanged onOpenDrama; + final VoidCallback onOpenFilter; + + @override + State createState() => _AllDramasFilterGridState(); +} + +class _AllDramasFilterGridState extends State { + String? _genre; + bool _newest = false; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final dramas = _filtered(); + return LayoutBuilder( + builder: (context, constraints) { + final itemWidth = + (constraints.maxWidth - + AppDimens.pageHorizontal * 2 - + AppDimens.homeGridGap * 2) / + 3; + final itemExtent = itemWidth * 6 / 5 + AppDimens.homeCardBodyExtent; + return NotificationListener( + onNotification: _onScroll, + child: AppPullToRefresh( + key: const Key('all-dramas-refresh'), + onRefresh: () async { + await widget.feedController.refreshCurrent(); + }, + child: CustomScrollView( + key: const Key('all-dramas-scroll'), + physics: AppPullToRefresh.physics, + slivers: [ + SliverToBoxAdapter(child: _filters(l10n)), + if (dramas.isEmpty) + SliverFillRemaining( + hasScrollBody: false, + child: EmptyState( + key: const Key('all-dramas-empty'), + icon: Icons.movie_filter_outlined, + title: l10n.homeGridFilterEmpty, + actionLabel: l10n.homeGridClearFilter, + onAction: () => setState(() => _genre = null), + ), + ) + else + SliverPadding( + padding: const EdgeInsets.symmetric( + horizontal: AppDimens.pageHorizontal, + ), + sliver: SliverGrid( + key: const Key('all-dramas-grid'), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + crossAxisSpacing: AppDimens.homeGridGap, + mainAxisSpacing: AppDimens.homeGridGap, + mainAxisExtent: itemExtent, + ), + delegate: SliverChildBuilderDelegate( + (context, index) => HomeFeedCard( + drama: dramas[index], + onTap: () => widget.onOpenDrama(dramas[index]), + ), + childCount: dramas.length, + ), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.only(bottom: AppDimens.spaceXl), + child: PagedListFooter( + loading: widget.feedController.loadingMore, + hasMore: widget.feedController.hasMore, + hasItems: dramas.isNotEmpty, + errorMessage: widget.feedController.lastError == null + ? null + : l10n.catalogError, + onRetry: widget.feedController.loadMore, + ), + ), + ), + ], + ), + ), + ); + }, + ); + } + + Widget _filters(AppLocalizations l10n) { + final genres = _availableGenres(l10n); + return Padding( + padding: const EdgeInsets.fromLTRB( + AppDimens.pageHorizontal, + AppDimens.spaceSm, + AppDimens.pageHorizontal, + AppDimens.spaceMd, + ), + child: Column( + key: const Key('all-dramas-filters'), + children: [ + _chipRow(0, [l10n.all]), + _chipRow(1, [l10n.all]), + _chipRow(2, [ + (id: null, label: l10n.all), + for (final genre in genres) (id: genre.id, label: genre.label), + ]), + Row( + key: const Key('all-filter-row-3'), + children: [ + Expanded( + child: Row( + children: [ + _chip( + key: const Key('all-sort-hot'), + label: l10n.hot, + selected: !_newest, + onTap: () => setState(() => _newest = false), + ), + const SizedBox(width: AppDimens.spaceSm), + _chip( + key: const Key('all-sort-new'), + label: l10n.newest, + selected: _newest, + onTap: () => setState(() => _newest = true), + ), + ], + ), + ), + IconButton( + key: const Key('all-dramas-filter-page'), + tooltip: l10n.filter, + onPressed: widget.onOpenFilter, + color: AppColors.textPrimary, + iconSize: AppDimens.tapTarget / 2, + icon: const Icon(Icons.filter_list_rounded), + ), + ], + ), + ], + ), + ); + } + + Widget _chipRow(int row, List options) { + return Padding( + key: Key('all-filter-row-$row'), + padding: const EdgeInsets.only(bottom: AppDimens.spaceSm), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + for (final option in options) ...[ + if (option is String) + _chip(label: option, selected: true, onTap: () {}), + if (option is ({String? id, String label})) + _chip( + label: option.label, + selected: _genre == option.id, + onTap: () => setState(() => _genre = option.id), + ), + const SizedBox(width: AppDimens.spaceSm), + ], + ], + ), + ), + ); + } + + Widget _chip({ + Key? key, + required String label, + required bool selected, + required VoidCallback onTap, + }) { + return InkWell( + key: key, + onTap: onTap, + borderRadius: BorderRadius.circular(AppDimens.pillRadius), + child: Container( + height: 32, + padding: const EdgeInsets.symmetric(horizontal: AppDimens.spaceMd), + alignment: Alignment.center, + decoration: BoxDecoration( + color: selected ? AppColors.contentChip : AppColors.contentSurface, + borderRadius: BorderRadius.circular(AppDimens.pillRadius), + ), + child: Text( + label, + style: TextStyle( + color: selected ? AppColors.textPrimary : AppColors.textSecondary, + fontSize: AppDimens.caption, + fontWeight: selected ? FontWeight.w700 : FontWeight.w500, + ), + ), + ), + ); + } + + List<({String id, String label})> _availableGenres(AppLocalizations l10n) { + final source = widget.feedController.dramas; + final definitions = [ + (id: 'reversal', label: l10n.reversalFilter, terms: _reversalTerms), + (id: 'uplifting', label: l10n.homeTagUplifting, terms: []), + (id: 'modern', label: l10n.modernFilter, terms: _modernTerms), + (id: 'sweet', label: l10n.homeTagSweet, terms: []), + (id: 'angst', label: l10n.homeTagAngst, terms: []), + ]; + return definitions + .where( + (definition) => source.any( + (drama) => _matchesGenre(drama, definition.id, definition.terms), + ), + ) + .map((definition) => (id: definition.id, label: definition.label)) + .toList(growable: false); + } + + List _filtered() { + final dramas = widget.feedController.dramas + .where((drama) { + return _genre == null || _matchesGenre(drama, _genre!, const []); + }) + .toList(growable: false); + if (_newest) { + return [...dramas]..sort((a, b) { + final left = publishedAtForDrama(a); + final right = publishedAtForDrama(b); + if (left == null && right == null) return b.id.compareTo(a.id); + if (left == null) return 1; + if (right == null) return -1; + return right.compareTo(left); + }); + } + return [...dramas]..sort((a, b) => b.viewCount.compareTo(a.viewCount)); + } + + bool _matchesGenre(Drama drama, String id, List terms) { + if (id == 'reversal' || + id == 'uplifting' || + id == 'sweet' || + id == 'angst') { + return matchesHomeTag(drama, id == 'reversal' ? 'uplifting' : id); + } + final text = '${drama.genre} ${drama.category}'.toLowerCase(); + return terms.any(text.contains); + } + + bool _onScroll(ScrollNotification notification) { + if (notification.depth != 0) return false; + final reachedBottom = + notification.metrics.extentAfter <= _loadMoreThreshold; + if (reachedBottom && + (notification is ScrollUpdateNotification || + notification is ScrollEndNotification || + notification is OverscrollNotification)) { + unawaited(widget.feedController.loadMore()); + } + return false; + } +} diff --git a/lib/pages/recommend/widgets/recommend_section_card.dart b/lib/pages/recommend/widgets/recommend_section_card.dart new file mode 100644 index 0000000..d2974a8 --- /dev/null +++ b/lib/pages/recommend/widgets/recommend_section_card.dart @@ -0,0 +1,116 @@ +// 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'; +import '../../home/widgets/home_drama_classification.dart'; + +class RecommendSectionCard extends StatelessWidget { + const RecommendSectionCard({ + super.key, + required this.sectionKey, + required this.drama, + required this.onTap, + }); + + final String sectionKey; + final Drama drama; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return SizedBox( + width: 122, + child: InkWell( + key: Key('recommend-card-$sectionKey-${drama.id}'), + borderRadius: BorderRadius.circular(AppDimens.homeCardRadius), + onTap: onTap, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(AppDimens.homeCardRadius), + child: AspectRatio( + aspectRatio: 3 / 4, + child: Stack( + fit: StackFit.expand, + children: [ + RemoteImage( + drama.posterUrl, + fit: BoxFit.cover, + showFallbackIcon: true, + fallbackColor: AppColors.contentSurface, + ), + if (isHotDrama(drama)) + Positioned( + top: AppDimens.spaceXs, + left: AppDimens.spaceXs, + child: Container( + key: Key('recommend-hot-$sectionKey-${drama.id}'), + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: AppColors.badgeHot, + borderRadius: BorderRadius.circular( + AppDimens.chipRadius, + ), + ), + child: Text( + l10n.homeBadgeHot, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: AppDimens.homeCardBadge, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ], + ), + ), + ), + const SizedBox(height: AppDimens.spaceXs), + Row( + children: [ + const Icon( + Icons.play_arrow_rounded, + color: AppColors.textSecondary, + size: AppDimens.homeCardMeta, + ), + Text( + formatInteractionCount(drama.viewCount), + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: AppDimens.homeCardMeta, + ), + ), + ], + ), + const SizedBox(height: 2), + Text( + drama.title, + key: Key('recommend-title-$sectionKey-${drama.id}'), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: AppDimens.homeCardTitle, + fontWeight: FontWeight.w600, + height: 1.25, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/recommend/widgets/recommend_sections_view.dart b/lib/pages/recommend/widgets/recommend_sections_view.dart new file mode 100644 index 0000000..704c9a1 --- /dev/null +++ b/lib/pages/recommend/widgets/recommend_sections_view.dart @@ -0,0 +1,209 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../../../core/data/models/content_category.dart'; +import '../../../core/data/models/drama.dart'; +import '../../../core/data/services/category_controller.dart'; +import '../../../core/data/services/home_feed_controller.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_dimens.dart'; +import '../../../core/widgets/app_pull_to_refresh.dart'; +import '../../../l10n/app_localizations.dart'; +import '../../home/widgets/home_drama_classification.dart'; +import 'recommend_section_card.dart'; + +const List _costumeNames = ['古装', 'costume', 'kostum']; + +enum RecommendSectionKind { hits, newReleases, costume } + +class RecommendSectionsView extends StatelessWidget { + const RecommendSectionsView({ + super.key, + required this.feedController, + required this.categories, + required this.onOpenDrama, + required this.onMore, + }); + + final HomeFeedController feedController; + final CategoryController categories; + final ValueChanged onOpenDrama; + final ValueChanged onMore; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final source = feedController.dramas; + final hits = [...source] + ..sort((a, b) => b.viewCount.compareTo(a.viewCount)); + final newest = [...source] + ..sort((a, b) { + final left = publishedAtForDrama(a); + final right = publishedAtForDrama(b); + if (left == null && right == null) return b.id.compareTo(a.id); + if (left == null) return 1; + if (right == null) return -1; + return right.compareTo(left); + }); + return AppPullToRefresh( + key: const Key('recommend-sections-refresh'), + onRefresh: () async { + await Future.wait([feedController.refreshCurrent(), categories.load()]); + }, + child: ListView( + key: const Key('recommend-sections-scroll'), + physics: AppPullToRefresh.physics, + padding: const EdgeInsets.only(bottom: AppDimens.spaceXl), + children: [ + _section( + context, + l10n, + key: 'hits', + title: l10n.weeklyHitsTitle, + subtitle: l10n.weeklyHitsSubtitle, + dramas: hits.take(10).toList(), + onMore: () => onMore(RecommendSectionKind.hits), + ), + const SizedBox(height: AppDimens.spaceXl), + _section( + context, + l10n, + key: 'new', + title: l10n.weeklyNewTitle, + subtitle: l10n.weeklyNewSubtitle, + dramas: newest.take(10).toList(), + onMore: () => onMore(RecommendSectionKind.newReleases), + ), + const SizedBox(height: AppDimens.spaceXl), + _section( + context, + l10n, + key: 'costume', + title: l10n.costumeAwardsTitle, + subtitle: l10n.costumeAwardsSubtitle, + dramas: _costumeDramas(source), + onMore: () => onMore(RecommendSectionKind.costume), + ), + ], + ), + ); + } + + List _costumeDramas(List source) { + final category = _costumeCategory(); + if (category != null) { + final fromCategory = categories.dramasOf(category.id); + if (fromCategory.isNotEmpty) return fromCategory.take(10).toList(); + } + return source + .where( + (drama) => + _containsCostume(drama.category) || _containsCostume(drama.genre), + ) + .take(10) + .toList(); + } + + ContentCategory? _costumeCategory() { + for (final category in categories.categories) { + if (_containsCostume(category.name)) return category; + } + return null; + } + + bool _containsCostume(String value) { + final normalized = value.toLowerCase(); + return _costumeNames.any(normalized.contains); + } + + Widget _section( + BuildContext context, + AppLocalizations l10n, { + required String key, + required String title, + required String subtitle, + required List dramas, + required VoidCallback onMore, + }) { + return Column( + key: Key('recommend-section-$key'), + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppDimens.pageHorizontal, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: AppDimens.sectionTitle, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + Text( + subtitle, + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: AppDimens.cardViews, + ), + ), + ], + ), + ), + TextButton( + key: Key('recommend-more-$key'), + onPressed: onMore, + style: TextButton.styleFrom( + foregroundColor: AppColors.textSecondary, + padding: const EdgeInsets.symmetric(horizontal: 4), + minimumSize: const Size(0, 30), + ), + child: Row( + children: [ + Text( + l10n.more, + style: const TextStyle( + fontSize: AppDimens.caption, + fontWeight: FontWeight.w500, + ), + ), + const Icon(Icons.chevron_right_rounded, size: 16), + ], + ), + ), + ], + ), + ), + const SizedBox(height: AppDimens.spaceMd), + SizedBox( + height: 218, + child: ListView.separated( + key: Key('recommend-row-$key'), + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric( + horizontal: AppDimens.pageHorizontal, + ), + itemCount: dramas.length, + separatorBuilder: (_, _) => + const SizedBox(width: AppDimens.spaceMd), + itemBuilder: (context, index) => RecommendSectionCard( + sectionKey: key, + drama: dramas[index], + onTap: () => onOpenDrama(dramas[index]), + ), + ), + ), + ], + ); + } +} diff --git a/test/discover/home_all_dramas_grid_test.dart b/test/discover/home_all_dramas_grid_test.dart index 697b52e..36bbb7b 100644 --- a/test/discover/home_all_dramas_grid_test.dart +++ b/test/discover/home_all_dramas_grid_test.dart @@ -79,7 +79,6 @@ void main() { catalogRepository: catalog, categoryRepository: _GridCategoryRepository(), ); - await tester.tap(find.byKey(const Key('theater-category-7'))); await tester.pumpAndSettle(); @@ -187,38 +186,6 @@ void main() { ); }); - testWidgets('should_load_next_theater_page_near_scroll_bottom', ( - tester, - ) async { - final catalog = _PagedTheaterCatalog([ - FeedPage(dramas: _pageDramas(0, 20), nextCursor: '20', hasMore: true), - FeedPage(dramas: _pageDramas(20, 20)), - ]); - await _pumpTheater( - tester, - catalogRepository: catalog, - categoryRepository: _GridCategoryRepository(), - ); - final position = tester - .state( - find - .descendant( - of: find.byKey(const Key('discover-refresh')), - matching: find.byType(Scrollable), - ) - .first, - ) - .position; - - position.jumpTo(position.maxScrollExtent); - await tester.pump(); - await tester.pumpAndSettle(); - - expect(catalog.calls, 2); - expect(catalog.requestedCursors, [null, '20']); - expect(find.byKey(const Key('home-drama-card-page-39')), findsOneWidget); - }); - testWidgets('should_refresh_feed_when_pulled_down', (tester) async { final counter = _CountingCategoryRepository( _GridCategoryRepository(discoverFeed: _tallDiscoverFeed), @@ -656,34 +623,6 @@ class _CountingTheaterCatalog extends _TheaterOnlyCatalog { } } -class _PagedTheaterCatalog extends _TheaterOnlyCatalog { - _PagedTheaterCatalog(this.pages); - - final List pages; - final List requestedCursors = []; - - int get calls => requestedCursors.length; - - @override - Future fetchFeed({ - List types = const [], - String? categoryId, - String? cursor, - int size = 10, - }) async { - if (size != 20) { - return super.fetchFeed( - types: types, - categoryId: categoryId, - cursor: cursor, - size: size, - ); - } - requestedCursors.add(cursor); - return pages.removeAt(0); - } -} - class _FailingTheaterCatalog extends _TheaterOnlyCatalog { @override Future fetchFeed({ @@ -703,14 +642,3 @@ class _FailingTheaterCatalog extends _TheaterOnlyCatalog { ); } } - -List _pageDramas(int start, int count) => [ - for (var index = start; index < start + count; index++) - Drama( - id: 'page-$index', - title: 'Page $index', - synopsis: 'Page $index synopsis', - posterUrl: '', - genre: 'Drama', - ), -]; diff --git a/test/home/home_grid_layout_test.dart b/test/home/home_grid_layout_test.dart index 5ae8b09..e07119b 100644 --- a/test/home/home_grid_layout_test.dart +++ b/test/home/home_grid_layout_test.dart @@ -124,7 +124,7 @@ void main() { ); await tester.pumpAndSettle(); expect(repository.cursors, contains('page1')); - expect(repository.requestedSizes.every((size) => size <= 10), isTrue); + expect(repository.requestedSizes.every((size) => size <= 20), isTrue); await tester.scrollUntilVisible( find.byKey(const Key('home-feed-card-page2-1')), diff --git a/test/home/home_nav_ui_test.dart b/test/home/home_nav_ui_test.dart index 3ba8edd..af25c79 100644 --- a/test/home/home_nav_ui_test.dart +++ b/test/home/home_nav_ui_test.dart @@ -213,7 +213,7 @@ void main() { await tester.tap(find.byKey(const Key('tab-recommend'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('home-feed-title')), findsOneWidget); + expect(find.byKey(const Key('recommend-page')), findsOneWidget); await tester.tap(find.byKey(const Key('tab-follow'))); await tester.pumpAndSettle(); diff --git a/test/home/home_preview_layout_test.dart b/test/home/home_preview_layout_test.dart index 7f36cd1..c5552ee 100644 --- a/test/home/home_preview_layout_test.dart +++ b/test/home/home_preview_layout_test.dart @@ -96,7 +96,9 @@ void main() { expect(tester.takeException(), isNull); }); - testWidgets('should_keep_immersive_player_on_recommend_tab', (tester) async { + testWidgets('should_show_section_feed_and_open_player_from_recommend', ( + tester, + ) async { SharedPreferencesAsyncPlatform.instance = InMemorySharedPreferencesAsync.empty(); tester.view.physicalSize = const Size(390, 844); @@ -123,9 +125,18 @@ void main() { await tester.pumpAndSettle(); expect(find.byKey(const Key('home-grid')), findsNothing); - expect(find.byType(PageView), findsOneWidget); - expect(tester.widget(find.byType(PlayerPage)).homeMode, isTrue); - expect(find.byKey(const Key('home-feed-title')), findsOneWidget); + expect(find.byKey(const Key('recommend-page')), findsOneWidget); + expect(find.byKey(const Key('recommend-section-hits')), findsOneWidget); + expect(find.byType(PlayerPage), findsNothing); + + await tester.tap( + find.byKey(const Key('recommend-card-hits-the-signal-demo')), + ); + await tester.pumpAndSettle(); + expect( + tester.widget(find.byType(PlayerPage).last).homeMode, + isTrue, + ); }); testWidgets('should_render_new_feed_structure_with_formatted_counts', ( diff --git a/test/home/k28_a_navigation_test.dart b/test/home/k28_a_navigation_test.dart index 461134f..006bac5 100644 --- a/test/home/k28_a_navigation_test.dart +++ b/test/home/k28_a_navigation_test.dart @@ -1,17 +1,13 @@ // Author: fruit.com.cn -import 'dart:async'; - import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:fruitshorts/app.dart'; -import 'package:fruitshorts/core/data/models/feed_page.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/core/widgets/app_bottom_nav.dart'; -import 'package:fruitshorts/pages/home/home_feed_channel.dart'; import 'package:fruitshorts/pages/player/player_view.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'; @@ -37,7 +33,6 @@ void main() { expect(find.byKey(key), findsOneWidget); } expect(find.byKey(const Key('tab-create')), findsNothing); - await tester.tap(find.byKey(const Key('tab-follow'))); await tester.pumpAndSettle(); @@ -52,21 +47,17 @@ void main() { expect(find.byKey(const Key('home-grid')), findsOneWidget); expect(find.byKey(const Key('home-grid-cards')), findsOneWidget); expect(find.byType(PageView), findsNothing); - expect( - tester.widget(find.byType(Scaffold).first).backgroundColor, - Colors.transparent, - ); expect(find.byKey(const Key('home-page-gradient')), findsNothing); expect(_hasLightStatusBar(tester), isTrue); }); - testWidgets('should_keep_home_feed_size_when_keyboard_insets_change', ( + testWidgets('should_keep_home_grid_when_keyboard_insets_change', ( tester, ) async { addTearDown(tester.view.resetViewInsets); await _pumpApp(tester); - final feed = find.byKey(const Key('home-grid')); - final before = tester.getRect(feed); + final grid = find.byKey(const Key('home-grid')); + final before = tester.getRect(grid); tester.view.viewInsets = const FakeViewPadding(bottom: 300); await tester.pump(); @@ -77,338 +68,83 @@ void main() { .resizeToAvoidBottomInset, isFalse, ); - expect(tester.getRect(feed), before); + expect(tester.getRect(grid), before); }); - testWidgets('should_open_full_player_from_full_series_bar', (tester) async { - await _pumpApp(tester, recommend: true); - - await tester.tap(find.byKey(const Key('home-feed-full-series'))); - await tester.pumpAndSettle(); - - final player = tester.widget(find.byType(PlayerPage).last); - expect(player.homeMode, isFalse); - }); - - testWidgets('should_switch_to_anime_channel', (tester) async { - await _pumpApp(tester, recommend: true); - - await tester.tap(find.byKey(const Key('home-feed-tab-anime'))); - await tester.pumpAndSettle(); - - expect(find.text('Coming soon'), findsNothing); - expect( - tester.widget(find.text('Anime')).style?.fontWeight, - FontWeight.w800, - ); - expect(find.byKey(const Key('home-feed-title')), findsOneWidget); - }); - - testWidgets('should_keep_player_when_first_anime_switch_is_loading', ( + testWidgets('should_render_recommend_sections_and_inner_top_nav', ( tester, ) async { - final catalog = _PendingAnimeCatalog(); - await _pumpApp(tester, catalog: catalog, recommend: true); - for (var attempt = 0; attempt < 5 && catalog.animeCalls == 0; attempt++) { - await tester.pump(); + await _pumpApp(tester); + await tester.tap(find.byKey(const Key('tab-recommend'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('recommend-page')), findsOneWidget); + expect(find.byKey(const Key('recommend-tab-recommend')), findsOneWidget); + expect(find.byKey(const Key('recommend-tab-playlist')), findsOneWidget); + expect(find.byKey(const Key('recommend-tab-all')), findsOneWidget); + expect(find.byKey(const Key('recommend-section-hits')), findsOneWidget); + expect(find.byKey(const Key('recommend-section-new')), findsOneWidget); + expect(find.byKey(const Key('recommend-section-costume')), findsOneWidget); + }); + + testWidgets('should_switch_playlist_and_all_dramas_in_recommend', ( + tester, + ) async { + await _pumpApp(tester); + await tester.tap(find.byKey(const Key('tab-recommend'))); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('recommend-tab-playlist'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('ranking-page')), findsOneWidget); + expect(find.byType(AppBottomNav), findsOneWidget); + + await tester.tap(find.byKey(const Key('recommend-tab-all'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('all-dramas-grid')), findsOneWidget); + for (var row = 0; row < 4; row++) { + expect(find.byKey(Key('all-filter-row-$row')), findsOneWidget); } - expect(catalog.animeCalls, 1); - - await tester.tap(find.byKey(const Key('home-feed-tab-anime'))); - await tester.pump(); - - expect(find.byType(PlayerPage), findsOneWidget); - expect(find.byKey(const Key('home-feed-channel-loading')), findsOneWidget); - - catalog.anime.complete(FeedPage(dramas: [demoDramas[1]])); - await tester.pumpAndSettle(); - - expect(find.byKey(const Key('home-feed-channel-loading')), findsNothing); - expect( - tester.widget(find.byType(PlayerPage)).feedChannel, - HomeFeedChannel.anime, - ); }); - testWidgets('should_show_empty_state_when_anime_has_no_dramas', ( - tester, - ) async { - await _pumpApp(tester, catalog: _EmptyAnimeCatalog(), recommend: true); - - await tester.tap(find.byKey(const Key('home-feed-tab-anime'))); - await tester.pumpAndSettle(); - - expect(find.byType(PlayerPage), findsNothing); - expect(find.text('No stories available yet.'), findsOneWidget); - }); - - testWidgets('should_refresh_feed_without_blank_loading', (tester) async { - await _pumpApp(tester, recommend: true); - - await tester.tap(find.byKey(const Key('home-feed-refresh'))); - await tester.pump(); - - expect(find.byType(CircularProgressIndicator), findsNothing); - expect(find.byKey(const Key('home-feed-title')), findsOneWidget); - await tester.pumpAndSettle(); - expect(find.byKey(const Key('home-feed-title')), findsOneWidget); - }); - - testWidgets('should_keep_feed_rail_above_bottom_panels', (tester) async { - await _pumpApp(tester, recommend: true); - final railTop = tester.getCenter(find.byKey(const Key('rail-save'))).dy; - final railBottom = tester - .getRect(find.byKey(const Key('rail-share'))) - .bottom; - final seriesTop = tester - .getRect(find.byKey(const Key('home-feed-full-series'))) - .top; - final titleRight = tester - .getRect(find.byKey(const Key('home-feed-title'))) - .right; - final railLeft = tester.getRect(find.byKey(const Key('rail-save'))).left; - - expect(railTop, greaterThan(844 * 0.45)); - expect(seriesTop - railBottom, closeTo(12, 1)); - expect(titleRight, lessThanOrEqualTo(railLeft + 2)); - }); - - testWidgets('should_keep_feed_header_and_progress_after_clear', ( - tester, - ) async { - await _pumpApp(tester, recommend: true); - final gesture = await tester.startGesture(const Offset(195, 300)); - await tester.pump(const Duration(milliseconds: 700)); - await tester.pump(); - await gesture.up(); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('player-more-clear'))); - await tester.pumpAndSettle(); - - expect(find.byKey(const Key('home-feed-refresh')), findsOneWidget); - expect(find.byKey(const Key('home-feed-tab-recommend')), findsOneWidget); - expect(find.byKey(const Key('home-feed-search')), findsOneWidget); - expect(find.byKey(const Key('home-feed-full-series')), findsOneWidget); - expect(find.byKey(const Key('home-feed-progress')), findsOneWidget); - expect(find.byKey(const Key('rail-save')), findsNothing); - expect(find.byKey(const Key('home-feed-title')), findsNothing); - final exit = tester.getRect( - find.byKey(const Key('home-feed-exit-clear-screen')), - ); - expect(exit.right, greaterThan(340)); - expect(exit.center.dy, greaterThan(600)); - - await tester.tap(find.byKey(const Key('home-feed-exit-clear-screen'))); - await tester.pumpAndSettle(); - expect(find.byKey(const Key('rail-save')), findsOneWidget); - expect(find.byKey(const Key('home-feed-title')), findsOneWidget); - }); - - testWidgets('should_boost_feed_on_side_long_press', (tester) async { - await _pumpApp(tester, recommend: true); - expect(find.byKey(const Key('home-feed-title')), findsOneWidget); - - final gesture = await tester.startGesture(const Offset(60, 300)); - await tester.pump(const Duration(milliseconds: 700)); - await tester.pump(); - - expect(find.byKey(const Key('home-feed-speed-boost-hint')), findsOneWidget); - expect(find.byKey(const Key('home-feed-full-series')), findsOneWidget); - expect(find.byKey(const Key('home-feed-title')), findsNothing); - expect(find.byKey(const Key('rail-save')), findsNothing); - expect(find.byKey(const Key('home-feed-search')), findsNothing); - - await gesture.up(); - await tester.pumpAndSettle(); - expect(find.byKey(const Key('home-feed-speed-boost-hint')), findsNothing); - expect(find.byKey(const Key('home-feed-title')), findsOneWidget); - expect(find.byKey(const Key('rail-save')), findsOneWidget); - expect(find.byKey(const Key('home-feed-search')), findsOneWidget); - }); - - testWidgets('should_hide_exit_clear_button_while_boosting', (tester) async { - await _pumpApp(tester, recommend: true); - final moreGesture = await tester.startGesture(const Offset(195, 300)); - await tester.pump(const Duration(milliseconds: 700)); - await tester.pump(); - await moreGesture.up(); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('player-more-clear'))); - await tester.pumpAndSettle(); - expect( - find.byKey(const Key('home-feed-exit-clear-screen')), - findsOneWidget, - ); - - final boostGesture = await tester.startGesture(const Offset(60, 300)); - await tester.pump(const Duration(milliseconds: 700)); - await tester.pump(); - - expect(find.byKey(const Key('home-feed-speed-boost-hint')), findsOneWidget); - expect(find.byKey(const Key('home-feed-full-series')), findsOneWidget); - expect(find.byKey(const Key('home-feed-progress')), findsOneWidget); - expect(find.byKey(const Key('home-feed-exit-clear-screen')), findsNothing); - expect(find.byKey(const Key('home-feed-search')), findsNothing); - - await boostGesture.up(); - await tester.pumpAndSettle(); - - expect(find.byKey(const Key('home-feed-speed-boost-hint')), findsNothing); - expect( - find.byKey(const Key('home-feed-exit-clear-screen')), - findsOneWidget, - ); - expect(find.byKey(const Key('home-feed-search')), findsOneWidget); - }); - - testWidgets('should_resume_playback_when_boosting_while_paused', ( - tester, - ) async { - await _pumpApp(tester, recommend: true); - await tester.tap(find.byKey(const Key('home-feed-video-toggle'))); - await tester.pump(); - expect(find.byKey(const Key('home-feed-center-play')), findsOneWidget); - - final gesture = await tester.startGesture(const Offset(60, 300)); - await tester.pump(const Duration(milliseconds: 700)); - await tester.pump(); - - expect(find.byKey(const Key('home-feed-speed-boost-hint')), findsOneWidget); - expect(find.textContaining('2.0'), findsWidgets); - expect(find.byKey(const Key('home-feed-center-play')), findsNothing); - - await gesture.up(); - await tester.pumpAndSettle(); - expect(find.byKey(const Key('home-feed-speed-boost-hint')), findsNothing); - expect(find.byKey(const Key('home-feed-center-play')), findsNothing); - }); - - testWidgets('should_open_more_sheet_on_center_long_press', (tester) async { - await _pumpApp(tester, recommend: true); - - final gesture = await tester.startGesture(const Offset(195, 300)); - await tester.pump(const Duration(milliseconds: 700)); - await tester.pump(); - await gesture.up(); - await tester.pumpAndSettle(); - - expect(find.byKey(const Key('player-more-sheet')), findsOneWidget); - expect(find.byKey(const Key('home-feed-clear')), findsNothing); - expect(find.byKey(const Key('home-feed-download')), findsNothing); - expect(find.byKey(const Key('home-feed-pip')), findsNothing); - }); - - testWidgets('should_keep_light_status_bar_in_grid_and_recommend', ( - tester, - ) async { + testWidgets('should_open_search_from_home_and_recommend', (tester) async { await _pumpApp(tester); - expect(_hasLightStatusBar(tester), isTrue); - - await tester.tap(find.byKey(const Key('tab-recommend'))); - await tester.pumpAndSettle(); - - expect(find.byKey(const Key('home-feed-title')), findsOneWidget); - expect(find.byKey(const Key('home-page-gradient')), findsNothing); - expect(_hasLightStatusBar(tester), isTrue); - }); - - testWidgets('should_switch_feed_drama_when_swiped_up', (tester) async { - await _pumpApp(tester, recommend: true); - final before = tester - .widget(find.byKey(const Key('home-feed-title'))) - .data; - - await tester.drag(find.byType(PageView).first, const Offset(0, -600)); - await tester.pumpAndSettle(); - - final after = tester - .widget(find.byKey(const Key('home-feed-title'))) - .data; - expect(after, isNot(before)); - }); - - testWidgets('should_keep_feed_header_fixed_when_swiping', (tester) async { - await _pumpApp(tester, recommend: true); - final before = tester.getRect(find.byKey(const Key('home-feed-search'))); - - await tester.drag(find.byType(PageView).first, const Offset(0, -600)); - await tester.pumpAndSettle(); - - final after = tester.getRect(find.byKey(const Key('home-feed-search'))); - expect(after.top, closeTo(before.top, 0.5)); - expect(find.byKey(const Key('home-feed-tab-recommend')), findsOneWidget); - expect(find.byKey(const Key('home-feed-refresh')), findsOneWidget); - }); - - testWidgets('should_resume_feed_after_returning_from_full_player', ( - tester, - ) async { - await _pumpApp(tester, recommend: true); - await tester.tap(find.byKey(const Key('home-feed-video-toggle'))); - await tester.pump(); - expect(find.byKey(const Key('home-feed-center-play')), findsOneWidget); - - await tester.tap(find.byKey(const Key('home-feed-full-series'))); - await tester.pumpAndSettle(); - expect( - tester.widget(find.byType(PlayerPage).last).homeMode, - isFalse, - ); - - await tester.tap(find.byKey(const Key('player-back'))); - await tester.pumpAndSettle(); - - expect(find.byKey(const Key('home-feed-title')), findsOneWidget); - expect(find.byKey(const Key('home-feed-center-play')), findsNothing); - }); - - testWidgets('should_switch_from_grid_to_recommend_player', (tester) async { - await _pumpApp(tester); - expect(find.byKey(const Key('home-grid')), findsOneWidget); - - await tester.tap(find.byKey(const Key('tab-recommend'))); - await tester.pumpAndSettle(); - - expect(find.byKey(const Key('home-grid')), findsNothing); - expect(find.byKey(const Key('home-feed-title')), findsOneWidget); - }); - - testWidgets('should_open_discover_search_from_home_search', (tester) async { - await _pumpApp(tester); - await tester.tap(find.byKey(const Key('home-search-entry'))); await tester.pumpAndSettle(); - - final field = tester.widget( - find.byKey(const Key('discover-search')), + expect( + tester + .widget(find.byKey(const Key('discover-search'))) + .focusNode + ?.hasFocus, + isTrue, ); - expect(field.focusNode?.hasFocus, isTrue); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('tab-recommend'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('recommend-search'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('discover-search')), findsOneWidget); }); - testWidgets('should_open_player_when_home_grid_card_is_tapped', ( - tester, - ) async { + testWidgets('should_open_player_from_home_grid_card', (tester) async { await _pumpApp(tester); - final card = find.byKey(const Key('home-feed-card-the-signal-demo')); - await tester.ensureVisible(card); - await tester.tap(card); + await tester.tap(find.byKey(const Key('home-feed-card-the-signal-demo'))); await tester.pumpAndSettle(); final player = tester.widget(find.byType(PlayerPage).last); expect(player.homeMode, isFalse); }); - testWidgets('should_fit_home_grid_and_recommend_on_320_width', ( - tester, - ) async { + testWidgets('should_fit_home_and_recommend_on_320_width', (tester) async { await _pumpApp(tester, size: const Size(320, 568)); - expect(find.byKey(const Key('home-grid-cards')), findsOneWidget); expect(tester.takeException(), isNull); await tester.tap(find.byKey(const Key('tab-recommend'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('home-feed-title')), findsOneWidget); + expect(find.byKey(const Key('recommend-section-hits')), findsOneWidget); expect(tester.takeException(), isNull); }); } @@ -429,21 +165,19 @@ bool _hasLightStatusBar(WidgetTester tester) => tester Future _pumpApp( WidgetTester tester, { Size size = const Size(390, 844), - FakeCatalogRepository? catalog, - bool recommend = false, }) async { tester.view.physicalSize = size; tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); - final repository = catalog ?? FakeCatalogRepository(); + final catalog = FakeCatalogRepository(); await tester.pumpWidget( LumaDramaApp( playerFactory: createWidgetPlayer, - catalogRepository: repository, - categoryRepository: FakeCategoryRepository(catalog: repository), + catalogRepository: catalog, + categoryRepository: FakeCategoryRepository(catalog: catalog), watchHistoryRepository: InMemoryWatchHistoryRepository(), - favoriteRepository: InMemoryFavoriteRepository(cards: repository.dramas), + favoriteRepository: InMemoryFavoriteRepository(cards: catalog.dramas), playbackRepository: FakePlaybackRepository(), commentRepository: FakeCommentRepository(), interactionRepository: FakeInteractionRepository(), @@ -451,52 +185,4 @@ Future _pumpApp( ), ); await tester.pumpAndSettle(); - if (recommend) { - await tester.tap(find.byKey(const Key('tab-recommend'))); - await tester.pumpAndSettle(); - } -} - -class _PendingAnimeCatalog extends FakeCatalogRepository { - final anime = Completer(); - int animeCalls = 0; - - @override - Future fetchFeed({ - List types = const [], - String? categoryId, - String? cursor, - int size = 10, - }) { - if (types.contains('ANIME')) { - animeCalls++; - return anime.future; - } - return super.fetchFeed( - types: types, - categoryId: categoryId, - cursor: cursor, - size: size, - ); - } -} - -class _EmptyAnimeCatalog extends FakeCatalogRepository { - @override - Future fetchFeed({ - List types = const [], - String? categoryId, - String? cursor, - int size = 10, - }) { - if (types.contains('ANIME')) { - return Future.value(const FeedPage(dramas: [])); - } - return super.fetchFeed( - types: types, - categoryId: categoryId, - cursor: cursor, - size: size, - ); - } } diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 93399f9..433f3e6 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -19,6 +19,7 @@ import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart import 'package:fruitshorts/core/data/services/playback_session.dart'; import 'package:fruitshorts/core/theme/app_colors.dart'; import 'package:fruitshorts/pages/auth/auth_view.dart'; +import 'package:fruitshorts/pages/player/player_view.dart'; import 'package:fruitshorts/pages/player/widgets/player_center_toggle.dart'; import 'package:fruitshorts/pages/player/widgets/seek_progress_bar.dart'; @@ -41,17 +42,16 @@ void main() { await _pumpApp(tester); await tester.tap(find.byKey(const Key('tab-recommend'))); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('home-feed-full-series'))); + await tester.tap( + find.byKey(const Key('recommend-card-hits-the-signal-demo')), + ); await tester.pumpAndSettle(); - _expectFullscreenOverlay(tester); + expect( + tester.widget(find.byType(PlayerPage).last).homeMode, + isTrue, + ); expect(find.byType(PageView), findsOneWidget); - await tester.tap(find.byKey(const Key('player-more'))); - await tester.pumpAndSettle(); - expect(find.byKey(const Key('player-more-quality-AUTO')), findsOneWidget); - expect(find.byKey(const Key('player-more-offline')), findsOneWidget); - await tester.tapAt(const Offset(10, 10)); - await tester.pumpAndSettle(); expect(tester.takeException(), isNull); }); @@ -61,7 +61,6 @@ void main() { await _pumpApp(tester); await tester.tap(find.byKey(const Key('tab-recommend'))); await tester.pumpAndSettle(); - _expectPlayerNavigationBar(tester); await _openSeriesPlayer(tester); _expectPlayerNavigationBar(tester); @@ -81,7 +80,12 @@ void main() { await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); await tester.tap(find.byKey(const Key('tab-recommend'))); + await tester.pumpAndSettle(); + await tester.tap( + find.byKey(const Key('recommend-card-hits-the-signal-demo')), + ); await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); final ticketLoading = find.byKey(const Key('player-ticket-loading')); expect(ticketLoading, findsOneWidget); @@ -166,6 +170,10 @@ void main() { ); await tester.tap(find.byKey(const Key('tab-recommend'))); await tester.pumpAndSettle(); + await tester.tap( + find.byKey(const Key('recommend-card-hits-the-signal-demo')), + ); + await tester.pumpAndSettle(); final player = players.firstWhere((item) => item.playing); expect(player.playing, isTrue); @@ -220,10 +228,8 @@ void main() { 'auth.user': '{"uid":"9","nickname":"Viewer","avatarUrl":""}', }); await _pumpApp(tester); - - await tester.tap(find.byKey(const Key('tab-recommend'))); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('rail-comments'))); + await _openSeriesPlayer(tester); + await tester.tap(find.byKey(const Key('player-rail-comments'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('player-comments-sheet')), findsOneWidget); @@ -266,34 +272,6 @@ void main() { ); }); - testWidgets('should_update_home_feed_favorite_count_after_toggle', ( - tester, - ) async { - SharedPreferencesAsyncPlatform.instance = - InMemorySharedPreferencesAsync.withData({ - 'auth.accessToken': 'access', - 'auth.refreshToken': 'refresh', - 'auth.user': '{"uid":"9","nickname":"Viewer","avatarUrl":""}', - }); - final catalog = FakeCatalogRepository(dramas: [_countingDrama]); - await _pumpApp(tester, catalog: catalog); - await tester.tap(find.byKey(const Key('tab-recommend'))); - await tester.pumpAndSettle(); - - expect( - tester.widget(find.byKey(const Key('rail-save-count'))).data, - '2', - ); - - await tester.tap(find.byKey(const Key('rail-save'))); - await tester.pumpAndSettle(); - - expect( - tester.widget(find.byKey(const Key('rail-save-count'))).data, - '3', - ); - }); - testWidgets('should_show_center_toggle_only_when_paused', (tester) async { await _pumpApp(tester); await _openSeriesPlayer(tester); @@ -1392,27 +1370,6 @@ Future _pumpApp( if (settle) await tester.pumpAndSettle(); } -const _countingDrama = Drama( - id: 'counting-demo', - title: 'Counting Demo', - synopsis: 'Counting interaction totals', - posterUrl: '/files/counting-demo.png', - genre: 'Romance', - detailLoaded: true, - favoriteCount: 2, - commentCount: 3, - likeCount: 4, - episodes: [ - Episode( - id: 'counting-demo-1', - number: 1, - title: 'Episode 1', - isFree: true, - videoUrl: 'https://example.test/counting-1.mp4', - ), - ], -); - class _RecordingPlayer implements PlayerPort { bool playing = false; diff --git a/test/recommend/recommend_sections_test.dart b/test/recommend/recommend_sections_test.dart new file mode 100644 index 0000000..fd2e8f4 --- /dev/null +++ b/test/recommend/recommend_sections_test.dart @@ -0,0 +1,136 @@ +// 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/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/core/widgets/app_bottom_nav.dart'; +import 'package:fruitshorts/pages/player/player_view.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_haptics.dart'; +import '../support/fake_interaction_repositories.dart'; +import '../support/widget_player.dart'; + +void main() { + setUp(() { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + }); + + testWidgets('should_render_three_recommend_sections_and_horizontal_cards', ( + tester, + ) async { + await _pumpRecommend(tester); + + expect(find.byKey(const Key('recommend-page')), findsOneWidget); + expect(find.byKey(const Key('recommend-tab-recommend')), findsOneWidget); + expect(find.byKey(const Key('recommend-tab-playlist')), findsOneWidget); + expect(find.byKey(const Key('recommend-tab-all')), findsOneWidget); + expect(find.byKey(const Key('recommend-section-hits')), findsOneWidget); + expect(find.byKey(const Key('recommend-section-new')), findsOneWidget); + expect(find.byKey(const Key('recommend-section-costume')), findsOneWidget); + expect(find.byKey(const Key('recommend-more-hits')), findsOneWidget); + expect(find.byKey(const Key('recommend-more-new')), findsOneWidget); + expect(find.byKey(const Key('recommend-more-costume')), findsOneWidget); + expect(find.byKey(const Key('recommend-row-hits')), findsOneWidget); + expect( + find.byKey(const Key('recommend-card-hits-the-signal-demo')), + findsOneWidget, + ); + expect(find.text("This Week's Hits"), findsOneWidget); + expect(find.text('New This Week'), findsOneWidget); + expect(find.text('Costume Drama Awards'), findsOneWidget); + expect(find.byType(AppBottomNav), findsOneWidget); + }); + + testWidgets('should_switch_inner_tabs_without_losing_grid_filter_state', ( + tester, + ) async { + await _pumpRecommend(tester); + + await tester.tap(find.byKey(const Key('recommend-tab-all'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('all-dramas-grid')), findsOneWidget); + for (var row = 0; row < 4; row++) { + expect(find.byKey(Key('all-filter-row-$row')), findsOneWidget); + } + expect(find.byKey(const Key('all-filter-local')), findsNothing); + expect(find.byKey(const Key('all-filter-subtitles')), findsNothing); + await tester.tap(find.byKey(const Key('all-sort-new'))); + await tester.pump(); + expect(_textStyle(tester, 'New').fontWeight, FontWeight.w700); + + await tester.tap(find.byKey(const Key('recommend-tab-playlist'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('ranking-page')), findsOneWidget); + expect(find.byType(AppBottomNav), findsOneWidget); + + await tester.tap(find.byKey(const Key('recommend-tab-all'))); + await tester.pumpAndSettle(); + expect(_textStyle(tester, 'New').fontWeight, FontWeight.w700); + expect(find.byKey(const Key('all-dramas-grid')), findsOneWidget); + }); + + testWidgets('should_open_player_and_search_from_recommend_page', ( + tester, + ) async { + await _pumpRecommend(tester); + + await tester.tap( + find.byKey(const Key('recommend-card-hits-the-signal-demo')), + ); + await tester.pumpAndSettle(); + final player = tester.widget(find.byType(PlayerPage).last); + expect(player.homeMode, isTrue); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('recommend-search'))); + await tester.pumpAndSettle(); + final search = tester.widget( + find.byKey(const Key('discover-search')), + ); + expect(search.focusNode?.hasFocus, isTrue); + }); +} + +TextStyle _textStyle(WidgetTester tester, String text) { + return tester + .widget( + find.descendant( + of: find.byKey(const Key('all-sort-new')), + matching: find.text(text), + ), + ) + .style!; +} + +Future _pumpRecommend(WidgetTester tester) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final catalog = FakeCatalogRepository(); + await tester.pumpWidget( + LumaDramaApp( + playerFactory: createWidgetPlayer, + catalogRepository: catalog, + categoryRepository: FakeCategoryRepository(catalog: catalog), + watchHistoryRepository: InMemoryWatchHistoryRepository(), + favoriteRepository: InMemoryFavoriteRepository(cards: catalog.dramas), + playbackRepository: FakePlaybackRepository(), + commentRepository: FakeCommentRepository(), + interactionRepository: FakeInteractionRepository(), + haptics: FakeHaptics(), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('tab-recommend'))); + await tester.pumpAndSettle(); +} diff --git a/test/widget_test.dart b/test/widget_test.dart index b5c7eca..b6b0eba 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -66,7 +66,7 @@ void main() { await tester.pumpAndSettle(); // 首页 feed 启动会创建首个播放器,先清掉再统计剧场播放器。 created.clear(); - await _openRecommendFullPlayer(tester); + await _openFullPlayerFromHomeGrid(tester); expect(created, isNotEmpty); final createdBeforeSwipe = created.length; @@ -79,7 +79,7 @@ void main() { contains('Episode 2'), ); expect(created.length, greaterThan(createdBeforeSwipe)); - expect(created, contains('the-signal-demo-2')); + expect(created, contains('city-lights-demo-2')); }); testWidgets('should_open_fullscreen_player_when_theater_card_is_tapped', ( @@ -87,7 +87,7 @@ void main() { ) async { await tester.pumpWidget(appUnderTest()); await tester.pumpAndSettle(); - await _openRecommendFullPlayer(tester); + await _openFullPlayerFromHomeGrid(tester); final player = tester.widget(find.byType(PlayerPage).last); expect(player.homeMode, isFalse); @@ -104,23 +104,17 @@ void main() { await tester.pumpWidget(appUnderTest()); await tester.pumpAndSettle(); - await _openRecommendFullPlayer(tester); + await _openFullPlayerFromHomeGrid(tester); expect(find.byType(PlayerPage), findsOneWidget); await tester.tap(find.byKey(const Key('player-back'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('home-feed-title')), findsOneWidget); + expect(find.byKey(const Key('home-grid')), findsOneWidget); expect( - Navigator.of( - tester.element(find.byKey(const Key('home-feed-title'))), - ).canPop(), + Navigator.of(tester.element(find.byKey(const Key('home-grid')))).canPop(), isFalse, ); - await tester.binding.handlePopRoute(); - await tester.pumpAndSettle(); - expect(find.byKey(const Key('home-grid')), findsOneWidget); - await tester.tap(find.byKey(const Key('tab-follow'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('favorites-page')), findsOneWidget); @@ -184,7 +178,7 @@ void main() { await tester.pumpWidget(appUnderTest()); await tester.pumpAndSettle(); - await _openRecommendFullPlayer(tester); + await _openFullPlayerFromHomeGrid(tester); expect(find.byKey(const Key('progress-loading')), findsNothing); expect(find.byKey(const Key('progress-inactive')), findsOneWidget); @@ -241,7 +235,7 @@ void main() { ), ); await tester.pumpAndSettle(); - await _openRecommendFullPlayer(tester); + await _openFullPlayerFromHomeGrid(tester); await tester.tap(find.byKey(const Key('player-rail-comments'))); await tester.pumpAndSettle(); @@ -261,13 +255,13 @@ void main() { await tester.tap(find.byKey(const Key('player-rail-like'))); await tester.pumpAndSettle(); - expect(interactions.likeTargets, contains('EPISODE:the-signal-demo-1')); + expect(interactions.likeTargets, contains('EPISODE:city-lights-demo-1')); await tester.tap(find.byKey(const Key('player-rail-save'))); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('player-rail-share'))); await tester.pumpAndSettle(); - expect(sharedText, contains('The Signal')); + expect(sharedText, contains('City Lights & Velvet Nights')); expect(sharedText, contains('FruitShorts')); expect(tester.takeException(), isNull); }); @@ -281,7 +275,7 @@ void main() { await tester.pumpWidget(appUnderTest(interactionRepository: interactions)); await tester.pumpAndSettle(); - await _openRecommendFullPlayer(tester); + await _openFullPlayerFromHomeGrid(tester); await tester.tap(find.byKey(const Key('player-rail-like'))); await tester.pumpAndSettle(); @@ -297,7 +291,7 @@ void main() { await tester.pumpWidget(appUnderTest()); await tester.pumpAndSettle(); - await _openRecommendFullPlayer(tester); + await _openFullPlayerFromHomeGrid(tester); await tester.tap(find.byKey(const Key('player-rail-save'))); await tester.pumpAndSettle(); @@ -308,13 +302,15 @@ void main() { await tester.tap(find.byKey(const Key('mine-watchlist'))); await tester.pumpAndSettle(); - expect(find.text('The Signal'), findsWidgets); + expect(find.text('City Lights & Velvet Nights'), findsWidgets); }); } -Future _openRecommendFullPlayer(WidgetTester tester) async { - await tester.tap(find.byKey(const Key('tab-recommend'))); +Future _openFullPlayerFromHomeGrid(WidgetTester tester) async { + await tester.tap(find.byKey(const Key('tab-home'))); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('home-feed-full-series'))); + final card = find.byKey(const Key('home-feed-card-city-lights-demo')); + await tester.ensureVisible(card); + await tester.tap(card); await tester.pumpAndSettle(); }