From 959edc0a32baab9e1425d84901668a40cd43ec2f Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 09:49:42 +0800 Subject: [PATCH] =?UTF-8?q?feat(flutter):=20=E9=87=8D=E6=9E=84=E5=9B=9B=20?= =?UTF-8?q?Tab=20=E4=B8=8E=E6=B5=8F=E8=A7=88=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/theme/app_dimens.dart | 10 + lib/core/widgets/app_bottom_nav.dart | 2 +- lib/core/widgets/remote_image.dart | 9 +- lib/l10n/app_en.arb | 30 + lib/l10n/app_id.arb | 30 + lib/l10n/app_localizations.dart | 114 +++ lib/l10n/app_localizations_en.dart | 61 ++ lib/l10n/app_localizations_id.dart | 62 ++ lib/l10n/app_localizations_zh.dart | 61 ++ lib/l10n/app_zh.arb | 30 + lib/pages/create/create_view.dart | 145 ++++ lib/pages/discover/discover_logic.dart | 16 + lib/pages/discover/discover_types.dart | 7 +- lib/pages/discover/discover_view.dart | 236 +++--- .../discover/widgets/discover_header.dart | 99 +-- .../widgets/discover_search_field.dart | 4 +- lib/pages/discover/widgets/drama_grid.dart | 72 +- .../widgets/feed_banner_carousel.dart | 43 +- lib/pages/home/home_browse_logic.dart | 70 ++ lib/pages/home/home_browse_view.dart | 333 +++++++++ lib/pages/home/home_logic.dart | 25 +- lib/pages/home/home_view.dart | 90 +-- .../home/widgets/home_browse_header.dart | 134 ++++ .../home/widgets/home_continue_watching.dart | 147 ++++ lib/pages/home/widgets/home_menu_sheet.dart | 2 +- lib/pages/mine/mine_types.dart | 2 +- lib/pages/mine/mine_view.dart | 13 +- lib/pages/player/player_view.dart | 185 ++--- test/account/mine_account_entry_test.dart | 4 +- test/discover/discover_banner_test.dart | 12 +- test/discover/home_all_dramas_grid_test.dart | 35 +- test/home/home_logic_test.dart | 20 +- test/home/home_nav_ui_test.dart | 40 +- test/home/home_preview_layout_test.dart | 54 +- test/home/k28_a_navigation_test.dart | 206 ++++++ test/mine/mine_preview_layout_test.dart | 2 +- test/mine/settings_page_test.dart | 2 +- test/notice/notices_entry_test.dart | 2 +- test/player/player_fullscreen_test.dart | 222 +----- test/watch/watch_history_provider_test.dart | 2 +- test/widget_test.dart | 694 +++++------------- 41 files changed, 2148 insertions(+), 1179 deletions(-) create mode 100644 lib/pages/create/create_view.dart create mode 100644 lib/pages/home/home_browse_logic.dart create mode 100644 lib/pages/home/home_browse_view.dart create mode 100644 lib/pages/home/widgets/home_browse_header.dart create mode 100644 lib/pages/home/widgets/home_continue_watching.dart create mode 100644 test/home/k28_a_navigation_test.dart diff --git a/lib/core/theme/app_dimens.dart b/lib/core/theme/app_dimens.dart index 9bc0cb6..32bfcda 100644 --- a/lib/core/theme/app_dimens.dart +++ b/lib/core/theme/app_dimens.dart @@ -42,6 +42,8 @@ abstract final class AppDimens { static const double buttonMedium = 40; static const double buttonLarge = 44; static const double tapTarget = 44; + static const double contentHeaderHeight = 46; + static const double filterChipHeight = 34; static const double navHeight = 54; static const double navIcon = 17; @@ -58,4 +60,12 @@ abstract final class AppDimens { static const double accountLabel = 11; static const double watchPlaceholderHeight = 114; + static const double heroBannerHeight = 176; + static const double continueCoverWidth = 52; + static const double continueCoverHeight = 66; + + static const double cardMeta = 9; + static const double cardViews = 9; + static const double createIcon = 22; + static const double createArrow = 18; } diff --git a/lib/core/widgets/app_bottom_nav.dart b/lib/core/widgets/app_bottom_nav.dart index 898d032..880ae8f 100644 --- a/lib/core/widgets/app_bottom_nav.dart +++ b/lib/core/widgets/app_bottom_nav.dart @@ -26,7 +26,7 @@ class AppBottomNavItem { final bool selected; } -/// Shared 3-tab navigation used by all root tabs and the player fallback. +/// Shared bottom navigation used by the root shell and standalone tabs. class AppBottomNav extends StatelessWidget { const AppBottomNav({ super.key, diff --git a/lib/core/widgets/remote_image.dart b/lib/core/widgets/remote_image.dart index 455a342..fd3a490 100644 --- a/lib/core/widgets/remote_image.dart +++ b/lib/core/widgets/remote_image.dart @@ -1,3 +1,5 @@ +// Author: fruit.com.cn + import 'package:flutter/material.dart'; import '../config/app_config.dart'; @@ -13,6 +15,7 @@ class RemoteImage extends StatelessWidget { this.fit = BoxFit.cover, this.showFallbackIcon = false, this.fallbackIconKey, + this.fallbackAspectRatio, }); final String path; @@ -21,6 +24,7 @@ class RemoteImage extends StatelessWidget { final BoxFit fit; final bool showFallbackIcon; final Key? fallbackIconKey; + final double? fallbackAspectRatio; @override Widget build(BuildContext context) { @@ -40,7 +44,7 @@ class RemoteImage extends StatelessWidget { } Widget _fallback() { - return Container( + final fallback = Container( width: width, height: height, color: AppColors.bar, @@ -54,5 +58,8 @@ class RemoteImage extends StatelessWidget { ) : null, ); + final ratio = fallbackAspectRatio; + if (ratio == null) return fallback; + return AspectRatio(aspectRatio: ratio, child: fallback); } } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 95aa570..9bc68f1 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -9,6 +9,8 @@ "demoTitle": "The Signal", "demoSynopsis": "A short local video preview. More episodes can be added when media is available.", "homeTab": "Home", + "theaterTab": "Theater", + "createTab": "Create", "openQuickMenu": "Open quick menu", "quickAccess": "Quick access", "currentSeries": "Current series", @@ -29,6 +31,7 @@ "filter": "Filter", "topCharts": "Top Charts", "newReleases": "New Releases", + "newDramas": "New", "trendingMicroDramas": "Trending Micro-Dramas", "seeAll": "See All", "trendingSubtitle": "Most binge-watched this week", @@ -66,6 +69,19 @@ "localViewer": "Local Viewer", "localDemoProfile": "Local demo profile", "continueWatching": "Continue Watching", + "episodeRemaining": "Episode {episode} · {minutes} min left", + "@episodeRemaining": { + "placeholders": { + "episode": {"type": "int"}, + "minutes": {"type": "int"} + } + }, + "viewCountLabel": "{count} views", + "@viewCountLabel": { + "placeholders": { + "count": {"type": "int"} + } + }, "watchedPercent": "Watched {percent}%", "@watchedPercent": { "placeholders": { @@ -88,6 +104,20 @@ "recommendedTab": "Recommended", "noCategoryDramas": "No dramas in this category yet.", "allDramas": "All Dramas", + "allLanguages": "All languages", + "bahasaMelayu": "Malay", + "createSubtitle": "Stories become short dramas", + "createAiTitle": "AI production", + "createAiSubtitle": "Start from a story or an idea", + "createUploadTitle": "Upload and publish", + "createUploadSubtitle": "Publish a finished short drama", + "weekdayMon": "Mon", + "weekdayTue": "Tue", + "weekdayWed": "Wed", + "weekdayThu": "Thu", + "weekdayFri": "Fri", + "weekdaySat": "Sat", + "weekdaySun": "Sun", "trendingTab": "Trending", "liveActionTab": "Live Action", "search": "Search", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index b30bb89..63617df 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -9,6 +9,8 @@ "demoTitle": "Sinyal", "demoSynopsis": "Pratinjau video lokal singkat. Episode lain bisa ditambahkan saat medianya tersedia.", "homeTab": "Beranda", + "theaterTab": "Teater", + "createTab": "Buat", "openQuickMenu": "Buka menu cepat", "quickAccess": "Akses cepat", "currentSeries": "Serial saat ini", @@ -29,6 +31,7 @@ "filter": "Filter", "topCharts": "Peringkat Teratas", "newReleases": "Rilisan Baru", + "newDramas": "Baru", "trendingMicroDramas": "Drama Mini Populer", "seeAll": "Lihat Semua", "trendingSubtitle": "Paling banyak ditonton minggu ini", @@ -66,6 +69,19 @@ "localViewer": "Penonton Lokal", "localDemoProfile": "Profil demo lokal", "continueWatching": "Lanjut Tonton", + "episodeRemaining": "Episode {episode} · {minutes} menit tersisa", + "@episodeRemaining": { + "placeholders": { + "episode": {"type": "int"}, + "minutes": {"type": "int"} + } + }, + "viewCountLabel": "{count} tayangan", + "@viewCountLabel": { + "placeholders": { + "count": {"type": "int"} + } + }, "watchedPercent": "Sudah ditonton {percent}%", "@watchedPercent": { "placeholders": { @@ -88,6 +104,20 @@ "recommendedTab": "Rekomendasi", "noCategoryDramas": "Belum ada drama di kategori ini.", "allDramas": "Semua Drama", + "allLanguages": "Semua bahasa", + "bahasaMelayu": "Bahasa Melayu", + "createSubtitle": "Cerita menjadi drama pendek", + "createAiTitle": "Produksi AI", + "createAiSubtitle": "Mulai dari cerita atau ide", + "createUploadTitle": "Unggah dan terbitkan", + "createUploadSubtitle": "Terbitkan drama pendek yang sudah selesai", + "weekdayMon": "Sen", + "weekdayTue": "Sel", + "weekdayWed": "Rab", + "weekdayThu": "Kam", + "weekdayFri": "Jum", + "weekdaySat": "Sab", + "weekdaySun": "Min", "trendingTab": "Sedang Tren", "liveActionTab": "Live Action", "search": "Cari", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index c814895..c184243 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -154,6 +154,18 @@ abstract class AppLocalizations { /// **'Home'** String get homeTab; + /// No description provided for @theaterTab. + /// + /// In en, this message translates to: + /// **'Theater'** + String get theaterTab; + + /// No description provided for @createTab. + /// + /// In en, this message translates to: + /// **'Create'** + String get createTab; + /// No description provided for @openQuickMenu. /// /// In en, this message translates to: @@ -274,6 +286,12 @@ abstract class AppLocalizations { /// **'New Releases'** String get newReleases; + /// No description provided for @newDramas. + /// + /// In en, this message translates to: + /// **'New'** + String get newDramas; + /// No description provided for @trendingMicroDramas. /// /// In en, this message translates to: @@ -430,6 +448,18 @@ abstract class AppLocalizations { /// **'Continue Watching'** String get continueWatching; + /// No description provided for @episodeRemaining. + /// + /// In en, this message translates to: + /// **'Episode {episode} · {minutes} min left'** + String episodeRemaining(int episode, int minutes); + + /// No description provided for @viewCountLabel. + /// + /// In en, this message translates to: + /// **'{count} views'** + String viewCountLabel(int count); + /// No description provided for @watchedPercent. /// /// In en, this message translates to: @@ -520,6 +550,90 @@ abstract class AppLocalizations { /// **'All Dramas'** String get allDramas; + /// No description provided for @allLanguages. + /// + /// In en, this message translates to: + /// **'All languages'** + String get allLanguages; + + /// No description provided for @bahasaMelayu. + /// + /// In en, this message translates to: + /// **'Malay'** + String get bahasaMelayu; + + /// No description provided for @createSubtitle. + /// + /// In en, this message translates to: + /// **'Stories become short dramas'** + String get createSubtitle; + + /// No description provided for @createAiTitle. + /// + /// In en, this message translates to: + /// **'AI production'** + String get createAiTitle; + + /// No description provided for @createAiSubtitle. + /// + /// In en, this message translates to: + /// **'Start from a story or an idea'** + String get createAiSubtitle; + + /// No description provided for @createUploadTitle. + /// + /// In en, this message translates to: + /// **'Upload and publish'** + String get createUploadTitle; + + /// No description provided for @createUploadSubtitle. + /// + /// In en, this message translates to: + /// **'Publish a finished short drama'** + String get createUploadSubtitle; + + /// No description provided for @weekdayMon. + /// + /// In en, this message translates to: + /// **'Mon'** + String get weekdayMon; + + /// No description provided for @weekdayTue. + /// + /// In en, this message translates to: + /// **'Tue'** + String get weekdayTue; + + /// No description provided for @weekdayWed. + /// + /// In en, this message translates to: + /// **'Wed'** + String get weekdayWed; + + /// No description provided for @weekdayThu. + /// + /// In en, this message translates to: + /// **'Thu'** + String get weekdayThu; + + /// No description provided for @weekdayFri. + /// + /// In en, this message translates to: + /// **'Fri'** + String get weekdayFri; + + /// No description provided for @weekdaySat. + /// + /// In en, this message translates to: + /// **'Sat'** + String get weekdaySat; + + /// No description provided for @weekdaySun. + /// + /// In en, this message translates to: + /// **'Sun'** + String get weekdaySun; + /// No description provided for @trendingTab. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index b33f730..10367de 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -36,6 +36,12 @@ class AppLocalizationsEn extends AppLocalizations { @override String get homeTab => 'Home'; + @override + String get theaterTab => 'Theater'; + + @override + String get createTab => 'Create'; + @override String get openQuickMenu => 'Open quick menu'; @@ -98,6 +104,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get newReleases => 'New Releases'; + @override + String get newDramas => 'New'; + @override String get trendingMicroDramas => 'Trending Micro-Dramas'; @@ -182,6 +191,16 @@ class AppLocalizationsEn extends AppLocalizations { @override String get continueWatching => 'Continue Watching'; + @override + String episodeRemaining(int episode, int minutes) { + return 'Episode $episode · $minutes min left'; + } + + @override + String viewCountLabel(int count) { + return '$count views'; + } + @override String watchedPercent(int percent) { return 'Watched $percent%'; @@ -229,6 +248,48 @@ class AppLocalizationsEn extends AppLocalizations { @override String get allDramas => 'All Dramas'; + @override + String get allLanguages => 'All languages'; + + @override + String get bahasaMelayu => 'Malay'; + + @override + String get createSubtitle => 'Stories become short dramas'; + + @override + String get createAiTitle => 'AI production'; + + @override + String get createAiSubtitle => 'Start from a story or an idea'; + + @override + String get createUploadTitle => 'Upload and publish'; + + @override + String get createUploadSubtitle => 'Publish a finished short drama'; + + @override + String get weekdayMon => 'Mon'; + + @override + String get weekdayTue => 'Tue'; + + @override + String get weekdayWed => 'Wed'; + + @override + String get weekdayThu => 'Thu'; + + @override + String get weekdayFri => 'Fri'; + + @override + String get weekdaySat => 'Sat'; + + @override + String get weekdaySun => 'Sun'; + @override String get trendingTab => 'Trending'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 88bef94..69ba775 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -36,6 +36,12 @@ class AppLocalizationsId extends AppLocalizations { @override String get homeTab => 'Beranda'; + @override + String get theaterTab => 'Teater'; + + @override + String get createTab => 'Buat'; + @override String get openQuickMenu => 'Buka menu cepat'; @@ -98,6 +104,9 @@ class AppLocalizationsId extends AppLocalizations { @override String get newReleases => 'Rilisan Baru'; + @override + String get newDramas => 'Baru'; + @override String get trendingMicroDramas => 'Drama Mini Populer'; @@ -182,6 +191,16 @@ class AppLocalizationsId extends AppLocalizations { @override String get continueWatching => 'Lanjut Tonton'; + @override + String episodeRemaining(int episode, int minutes) { + return 'Episode $episode · $minutes menit tersisa'; + } + + @override + String viewCountLabel(int count) { + return '$count tayangan'; + } + @override String watchedPercent(int percent) { return 'Sudah ditonton $percent%'; @@ -229,6 +248,49 @@ class AppLocalizationsId extends AppLocalizations { @override String get allDramas => 'Semua Drama'; + @override + String get allLanguages => 'Semua bahasa'; + + @override + String get bahasaMelayu => 'Bahasa Melayu'; + + @override + String get createSubtitle => 'Cerita menjadi drama pendek'; + + @override + String get createAiTitle => 'Produksi AI'; + + @override + String get createAiSubtitle => 'Mulai dari cerita atau ide'; + + @override + String get createUploadTitle => 'Unggah dan terbitkan'; + + @override + String get createUploadSubtitle => + 'Terbitkan drama pendek yang sudah selesai'; + + @override + String get weekdayMon => 'Sen'; + + @override + String get weekdayTue => 'Sel'; + + @override + String get weekdayWed => 'Rab'; + + @override + String get weekdayThu => 'Kam'; + + @override + String get weekdayFri => 'Jum'; + + @override + String get weekdaySat => 'Sab'; + + @override + String get weekdaySun => 'Min'; + @override String get trendingTab => 'Sedang Tren'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 6a31d17..2256dbc 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -35,6 +35,12 @@ class AppLocalizationsZh extends AppLocalizations { @override String get homeTab => '首页'; + @override + String get theaterTab => '剧场'; + + @override + String get createTab => '创作'; + @override String get openQuickMenu => '打开快捷菜单'; @@ -95,6 +101,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get newReleases => '新剧'; + @override + String get newDramas => '新剧'; + @override String get trendingMicroDramas => '热门短剧'; @@ -177,6 +186,16 @@ class AppLocalizationsZh extends AppLocalizations { @override String get continueWatching => '继续观看'; + @override + String episodeRemaining(int episode, int minutes) { + return '第 $episode 集 · 还剩 $minutes 分钟'; + } + + @override + String viewCountLabel(int count) { + return '$count 次播放'; + } + @override String watchedPercent(int percent) { return '已观看 $percent%'; @@ -224,6 +243,48 @@ class AppLocalizationsZh extends AppLocalizations { @override String get allDramas => '全部剧目'; + @override + String get allLanguages => '全部语言'; + + @override + String get bahasaMelayu => '马来语'; + + @override + String get createSubtitle => '让故事成为漫剧'; + + @override + String get createAiTitle => 'AI 制作'; + + @override + String get createAiSubtitle => '从故事或想法开始'; + + @override + String get createUploadTitle => '上传发布'; + + @override + String get createUploadSubtitle => '发布已完成的漫剧'; + + @override + String get weekdayMon => '周一'; + + @override + String get weekdayTue => '周二'; + + @override + String get weekdayWed => '周三'; + + @override + String get weekdayThu => '周四'; + + @override + String get weekdayFri => '周五'; + + @override + String get weekdaySat => '周六'; + + @override + String get weekdaySun => '周日'; + @override String get trendingTab => '热门'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index f63ec02..a434316 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -9,6 +9,8 @@ "demoTitle": "信号", "demoSynopsis": "使用本地视频进行播放预览,后续有素材再增加剧集。", "homeTab": "首页", + "theaterTab": "剧场", + "createTab": "创作", "openQuickMenu": "打开快捷菜单", "quickAccess": "快捷入口", "currentSeries": "当前短剧", @@ -29,6 +31,7 @@ "filter": "筛选", "topCharts": "排行榜", "newReleases": "新剧", + "newDramas": "新剧", "trendingMicroDramas": "热门短剧", "seeAll": "查看全部", "trendingSubtitle": "本周追看最多", @@ -66,6 +69,19 @@ "localViewer": "本地观众", "localDemoProfile": "本地演示档案", "continueWatching": "继续观看", + "episodeRemaining": "第 {episode} 集 · 还剩 {minutes} 分钟", + "@episodeRemaining": { + "placeholders": { + "episode": {"type": "int"}, + "minutes": {"type": "int"} + } + }, + "viewCountLabel": "{count} 次播放", + "@viewCountLabel": { + "placeholders": { + "count": {"type": "int"} + } + }, "watchedPercent": "已观看 {percent}%", "@watchedPercent": { "placeholders": { @@ -88,6 +104,20 @@ "recommendedTab": "推荐", "noCategoryDramas": "这个分类暂时没有剧集。", "allDramas": "全部剧目", + "allLanguages": "全部语言", + "bahasaMelayu": "马来语", + "createSubtitle": "让故事成为漫剧", + "createAiTitle": "AI 制作", + "createAiSubtitle": "从故事或想法开始", + "createUploadTitle": "上传发布", + "createUploadSubtitle": "发布已完成的漫剧", + "weekdayMon": "周一", + "weekdayTue": "周二", + "weekdayWed": "周三", + "weekdayThu": "周四", + "weekdayFri": "周五", + "weekdaySat": "周六", + "weekdaySun": "周日", "trendingTab": "热门", "liveActionTab": "真人剧", "search": "搜索", diff --git a/lib/pages/create/create_view.dart b/lib/pages/create/create_view.dart new file mode 100644 index 0000000..0ff779c --- /dev/null +++ b/lib/pages/create/create_view.dart @@ -0,0 +1,145 @@ +// Author: fruit.com.cn + +// K28 creation tab placeholder; creator workflows land in the next batch. +import 'package:flutter/material.dart'; + +import '../../core/theme/app_colors.dart'; +import '../../core/theme/app_dimens.dart'; +import '../../core/theme/app_theme.dart'; +import '../../l10n/app_localizations.dart'; + +/// Honest skeleton for the future creation workspace. +class CreatePage extends StatelessWidget { + const CreatePage({super.key}); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return Theme( + data: AppTheme.dark(), + child: SafeArea( + bottom: false, + child: ListView( + key: const Key('create-page'), + padding: const EdgeInsets.fromLTRB( + AppDimens.pageHorizontal, + AppDimens.spaceXl, + AppDimens.pageHorizontal, + AppDimens.spaceXl, + ), + children: [ + Text( + l10n.createTab, + style: const TextStyle( + fontFamily: 'Outfit', + color: AppColors.onSurface, + fontSize: AppDimens.pageTitle, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: AppDimens.spaceSm), + Text( + l10n.createSubtitle, + style: const TextStyle( + color: AppColors.muted, + fontSize: AppDimens.body, + ), + ), + const SizedBox(height: AppDimens.spaceXl), + _CreateEntry( + key: const Key('create-ai'), + icon: Icons.auto_awesome_rounded, + title: l10n.createAiTitle, + subtitle: l10n.createAiSubtitle, + detail: l10n.comingSoon, + ), + const SizedBox(height: AppDimens.spaceMd), + _CreateEntry( + key: const Key('create-upload'), + icon: Icons.upload_file_rounded, + title: l10n.createUploadTitle, + subtitle: l10n.createUploadSubtitle, + detail: l10n.comingSoon, + ), + ], + ), + ), + ); + } +} + +class _CreateEntry extends StatelessWidget { + const _CreateEntry({ + super.key, + required this.icon, + required this.title, + required this.subtitle, + required this.detail, + }); + + final IconData icon; + final String title; + final String subtitle; + final String detail; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(AppDimens.cardPadding), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppDimens.cardRadius), + border: Border.all(color: AppColors.border), + ), + child: Row( + children: [ + Container( + width: AppDimens.tapTarget, + height: AppDimens.tapTarget, + decoration: BoxDecoration( + color: AppColors.blue.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(AppDimens.innerRadius), + ), + child: Icon( + icon, + color: AppColors.blue, + size: AppDimens.createIcon, + ), + ), + const SizedBox(width: AppDimens.spaceMd), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: AppDimens.listTitle, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: AppDimens.spaceXs), + Text( + '$subtitle · $detail', + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.muted, + fontSize: AppDimens.caption, + ), + ), + ], + ), + ), + const SizedBox(width: AppDimens.spaceSm), + const Icon( + Icons.chevron_right_rounded, + color: AppColors.subtle, + size: AppDimens.createArrow, + ), + ], + ), + ); + } +} diff --git a/lib/pages/discover/discover_logic.dart b/lib/pages/discover/discover_logic.dart index 7df910f..b654e35 100644 --- a/lib/pages/discover/discover_logic.dart +++ b/lib/pages/discover/discover_logic.dart @@ -28,6 +28,8 @@ class DiscoverLogic extends ChangeNotifier { String _lastRequestedQuery = ''; int _searchRequest = 0; bool _disposed = false; + TheaterLanguage language = TheaterLanguage.all; + String? selectedCategoryId; bool get hasSearchQuery => search.text.trim().isNotEmpty; @@ -48,6 +50,20 @@ class DiscoverLogic extends ChangeNotifier { void openSearch() => searchFocus.requestFocus(); + /// Selects a language for the visual filter state. + void selectLanguage(TheaterLanguage value) { + if (language == value) return; + language = value; + notify(); + } + + /// Selects one category chip; null represents all categories. + void selectCategory(String? categoryId) { + if (selectedCategoryId == categoryId) return; + selectedCategoryId = categoryId; + notify(); + } + void retrySearch() { final query = search.text.trim(); if (query.isNotEmpty) unawaited(_runSearch(query)); diff --git a/lib/pages/discover/discover_types.dart b/lib/pages/discover/discover_types.dart index a609965..25e3f47 100644 --- a/lib/pages/discover/discover_types.dart +++ b/lib/pages/discover/discover_types.dart @@ -4,8 +4,11 @@ import 'package:flutter/foundation.dart'; import '../../core/data/models/drama.dart'; -/// 剧场页与播放器之间的返回动作。 -enum DiscoverPageAction { home, my } +/// 剧场页与根导航之间的动作。 +enum DiscoverPageAction { home, create, my } + +/// Language choices shown by the theater filter UI. +enum TheaterLanguage { all, zh, id, ms, en } /// 剧场页导航回调;没有 `onAction` 时按独立页面处理。 class DiscoverPageCallbacks { diff --git a/lib/pages/discover/discover_view.dart b/lib/pages/discover/discover_view.dart index bfa0d41..3f7a245 100644 --- a/lib/pages/discover/discover_view.dart +++ b/lib/pages/discover/discover_view.dart @@ -4,12 +4,8 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; -import '../../core/data/models/content_category.dart'; import '../../core/data/models/drama.dart'; -import '../../core/data/models/home_feed.dart'; -import '../../core/data/repositories/catalog_repository.dart'; import '../../core/data/repositories/search_repository.dart'; -import '../../core/data/services/catalog_controller.dart'; import '../../core/data/services/category_controller.dart'; import '../../core/formatting/error_text.dart'; import '../../core/theme/app_colors.dart'; @@ -19,19 +15,14 @@ import '../../core/widgets/app_bottom_nav.dart'; import '../../core/widgets/empty_state.dart'; import '../../l10n/app_localizations.dart'; import '../drama_detail/drama_detail_view.dart'; -import 'category_dramas_page.dart'; import 'discover_binding.dart'; import 'discover_logic.dart'; import 'discover_types.dart'; -import 'widgets/category_grid.dart'; import 'widgets/discover_header.dart'; -import 'widgets/discover_search_field.dart'; import 'widgets/discover_search_results.dart'; import 'widgets/drama_grid.dart'; -import 'widgets/feed_banner_carousel.dart'; -import 'widgets/ranking_section.dart'; -/// 剧场页:搜索 + 分类九宫格 + 榜单 + 全部剧目。 +/// Theater page with fixed search, category chips, and a two-column catalogue. class DiscoverPage extends StatefulWidget { const DiscoverPage({ super.key, @@ -96,13 +87,18 @@ class _DiscoverPageState extends State { Widget _page(BuildContext context) { final l10n = AppLocalizations.of(context)!; final categories = context.watch(); - final banners = context.watch().banners; final content = SafeArea( bottom: !embedded, child: Column( children: [ - DiscoverHeader(onSearch: logic.openSearch, onProfile: _openProfile), - Expanded(child: _feed(l10n, categories, banners)), + DiscoverHeader( + controller: logic.search, + focusNode: logic.searchFocus, + onChanged: (_) => logic.onSearchChanged(), + loading: logic.searchLoading, + onProfile: _openProfile, + ), + Expanded(child: _feed(l10n, categories)), if (!embedded) _bottomNav(l10n), ], ), @@ -118,43 +114,28 @@ class _DiscoverPageState extends State { ); } - Widget _feed( - AppLocalizations l10n, - CategoryController categories, - List banners, - ) { - return RefreshIndicator( - key: const Key('discover-refresh'), - color: AppColors.blue, - backgroundColor: AppColors.card, - onRefresh: categories.load, - child: ListView( - controller: logic.scroll, - physics: const AlwaysScrollableScrollPhysics(), - padding: const EdgeInsets.only(bottom: 24), - children: [ - Padding( - padding: const EdgeInsets.only(top: 8), - child: DiscoverSearchField( - controller: logic.search, - focusNode: logic.searchFocus, - onChanged: (_) => logic.onSearchChanged(), - loading: logic.searchLoading, - ), - ), - ..._content(l10n, categories, banners), - ], + Widget _feed(AppLocalizations l10n, CategoryController categories) { + return GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: logic.searchFocus.unfocus, + child: RefreshIndicator( + key: const Key('discover-refresh'), + color: AppColors.blue, + backgroundColor: AppColors.card, + onRefresh: categories.load, + child: ListView( + controller: logic.scroll, + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.only(bottom: 24), + children: _content(l10n, categories), + ), ), ); } - List _content( - AppLocalizations l10n, - CategoryController categories, - List banners, - ) { + List _content(AppLocalizations l10n, CategoryController categories) { if (logic.hasSearchQuery) return [_searchContent(l10n)]; - return _theaterContent(l10n, categories, banners); + return _theaterContent(l10n, categories); } Widget _searchContent(AppLocalizations l10n) => DiscoverSearchResults( @@ -168,7 +149,6 @@ class _DiscoverPageState extends State { List _theaterContent( AppLocalizations l10n, CategoryController categories, - List banners, ) { if (categories.loading && categories.feed.sections.isEmpty) { return [_block(const CircularProgressIndicator(strokeWidth: 2))]; @@ -188,38 +168,19 @@ class _DiscoverPageState extends State { ), ]; } - return _theaterBlocks(l10n, categories, banners); + return _theaterBlocks(l10n, categories); } List _theaterBlocks( AppLocalizations l10n, CategoryController categories, - List banners, ) { - final dramas = categories.feed.dramas; + final dramas = logic.selectedCategoryId == null + ? categories.feed.dramas + : categories.dramasOf(logic.selectedCategoryId!); return [ - Padding( - padding: const EdgeInsets.only(top: 12), - child: CategoryGrid( - categories: categories.categories, - onTap: (category) => _openCategory(category, categories), - ), - ), - if (banners.isNotEmpty) - Padding( - padding: const EdgeInsets.only(top: 12), - child: FeedBannerCarousel( - banners: banners, - active: widget.active, - onOpen: _openBanner, - ), - ), - if (categories.feed.sections.isNotEmpty || dramas.isNotEmpty) - RankingSection( - sections: categories.feed.sections, - fallback: dramas, - onOpen: _openDrama, - ), + _categoryChips(l10n, categories), + _languageChips(l10n), _sectionTitle(l10n.allDramas), if (dramas.isEmpty) _emptyCatalog(l10n) else _dramaGrid(l10n, dramas), ]; @@ -242,6 +203,93 @@ class _DiscoverPageState extends State { return DramaGrid(l10n: l10n, dramas: dramas, onOpen: _openDrama); } + Widget _categoryChips(AppLocalizations l10n, CategoryController categories) { + return Padding( + padding: const EdgeInsets.fromLTRB( + AppDimens.pageHorizontal, + AppDimens.spaceMd, + AppDimens.pageHorizontal, + 0, + ), + child: Wrap( + key: const Key('theater-category-chips'), + spacing: AppDimens.spaceSm, + runSpacing: AppDimens.spaceSm, + children: [ + _filterChip( + key: const Key('theater-category-all'), + label: l10n.all, + selected: logic.selectedCategoryId == null, + onSelected: () => logic.selectCategory(null), + ), + for (final category in categories.categories) + _filterChip( + key: Key('theater-category-${category.id}'), + label: category.name, + selected: logic.selectedCategoryId == category.id, + onSelected: () => logic.selectCategory(category.id), + ), + ], + ), + ); + } + + Widget _languageChips(AppLocalizations l10n) { + final entries = [ + (TheaterLanguage.all, l10n.allLanguages), + (TheaterLanguage.zh, l10n.simplifiedChinese), + (TheaterLanguage.id, l10n.bahasaIndonesia), + (TheaterLanguage.ms, l10n.bahasaMelayu), + (TheaterLanguage.en, l10n.english), + ]; + return Padding( + padding: const EdgeInsets.fromLTRB( + AppDimens.pageHorizontal, + AppDimens.spaceSm, + AppDimens.pageHorizontal, + 0, + ), + child: Wrap( + key: const Key('theater-language-chips'), + spacing: AppDimens.spaceSm, + runSpacing: AppDimens.spaceSm, + children: [ + for (final entry in entries) + _filterChip( + key: Key('theater-language-${entry.$1.name}'), + label: entry.$2, + selected: logic.language == entry.$1, + onSelected: () => logic.selectLanguage(entry.$1), + ), + ], + ), + ); + } + + Widget _filterChip({ + required Key key, + required String label, + required bool selected, + required VoidCallback onSelected, + }) { + return ChoiceChip( + key: key, + label: Text(label), + selected: selected, + showCheckmark: false, + onSelected: (_) => onSelected(), + backgroundColor: AppColors.card, + selectedColor: AppColors.blue, + side: BorderSide(color: selected ? AppColors.blue : AppColors.border), + labelStyle: TextStyle( + color: selected ? AppColors.onPrimary : AppColors.muted, + fontSize: AppDimens.chipFont, + fontWeight: FontWeight.w700, + ), + shape: const StadiumBorder(), + ); + } + Widget _sectionTitle(String title) { return Padding( padding: const EdgeInsets.fromLTRB(14, 18, 14, 10), @@ -291,18 +339,6 @@ class _DiscoverPageState extends State { ); } - void _openCategory(ContentCategory category, CategoryController categories) { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => CategoryDramasPage( - title: category.name, - dramas: categories.dramasOf(category.id), - onOpen: widget.callbacks.onDramaSelected == null ? null : _openDrama, - ), - ), - ); - } - void _openDrama(Drama drama) { final callback = widget.callbacks.onDramaSelected; if (callback != null) { @@ -318,33 +354,12 @@ class _DiscoverPageState extends State { ); } - Future _openBanner(FeedBanner banner) async { - final dramaId = banner.dramaId; - if (dramaId == null) return; - for (final drama in context.read().dramas) { - if (drama.id == dramaId) { - _pushDetail(drama); - return; - } - } - try { - final drama = await context.read().fetchDrama(dramaId); - if (mounted) _pushDetail(drama); - } catch (_) { - if (!mounted) return; - final message = AppLocalizations.of(context)!.detailLoadFailed; - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(message))); - } - } - Widget _bottomNav(AppLocalizations l10n) => AppBottomNav( items: [ AppBottomNavItem( tabKey: const Key('discover-nav-home'), - icon: Icons.play_circle_outline_rounded, - selectedIcon: Icons.play_circle_fill_rounded, + icon: Icons.home_outlined, + selectedIcon: Icons.home_rounded, label: l10n.homeTab, onTap: () => _act(DiscoverPageAction.home), ), @@ -352,10 +367,17 @@ class _DiscoverPageState extends State { tabKey: const Key('discover-nav-current'), icon: Icons.explore_outlined, selectedIcon: Icons.explore_rounded, - label: l10n.discoverTab, + label: l10n.theaterTab, selected: true, onTap: () {}, ), + AppBottomNavItem( + tabKey: const Key('discover-nav-create'), + icon: Icons.add_box_outlined, + selectedIcon: Icons.add_box_rounded, + label: l10n.createTab, + onTap: () => _act(DiscoverPageAction.create), + ), AppBottomNavItem( tabKey: const Key('discover-nav-my'), icon: Icons.video_library_outlined, diff --git a/lib/pages/discover/widgets/discover_header.dart b/lib/pages/discover/widgets/discover_header.dart index 1d20061..f034c89 100644 --- a/lib/pages/discover/widgets/discover_header.dart +++ b/lib/pages/discover/widgets/discover_header.dart @@ -3,37 +3,48 @@ import 'package:flutter/material.dart'; import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_dimens.dart'; import '../../../l10n/app_localizations.dart'; +import 'discover_search_field.dart'; -/// 剧场页页头:品牌、搜索与个人页入口。 +/// Theater header with a persistent search field and profile entry. class DiscoverHeader extends StatelessWidget { const DiscoverHeader({ super.key, - required this.onSearch, + required this.controller, + required this.focusNode, + required this.onChanged, + required this.loading, required this.onProfile, }); - final VoidCallback onSearch; + final TextEditingController controller; + final FocusNode focusNode; + final ValueChanged onChanged; + final bool loading; final VoidCallback onProfile; @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; return SizedBox( - height: 46, + height: AppDimens.contentHeaderHeight, child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 14), + padding: const EdgeInsets.symmetric( + horizontal: AppDimens.pageHorizontal, + ), child: Row( children: [ - _brand(l10n), - const Spacer(), - _HeaderIcon( - key: const Key('discover-search-button'), - icon: Icons.search_rounded, - tooltip: l10n.search, - onTap: onSearch, + Expanded( + child: DiscoverSearchField( + controller: controller, + focusNode: focusNode, + onChanged: onChanged, + loading: loading, + horizontalPadding: 0, + ), ), - const SizedBox(width: 8), + const SizedBox(width: AppDimens.spaceSm), _profile(l10n), ], ), @@ -41,37 +52,6 @@ class DiscoverHeader extends StatelessWidget { ); } - Widget _brand(AppLocalizations l10n) { - return Row( - children: [ - Container( - width: 24, - height: 24, - alignment: Alignment.center, - decoration: BoxDecoration( - color: AppColors.blue, - borderRadius: BorderRadius.circular(7), - ), - child: const Icon( - Icons.play_arrow_rounded, - color: AppColors.onPrimary, - size: 16, - ), - ), - const SizedBox(width: 8), - Text( - l10n.discoverTab, - style: const TextStyle( - fontFamily: 'Outfit', - color: AppColors.onSurface, - fontSize: 15, - fontWeight: FontWeight.w800, - ), - ), - ], - ); - } - Widget _profile(AppLocalizations l10n) { return Semantics( button: true, @@ -101,34 +81,3 @@ class DiscoverHeader extends StatelessWidget { ); } } - -class _HeaderIcon extends StatelessWidget { - const _HeaderIcon({ - super.key, - required this.icon, - required this.tooltip, - required this.onTap, - }); - - final IconData icon; - final String tooltip; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - return SizedBox( - width: 36, - height: 36, - child: IconButton( - tooltip: tooltip, - onPressed: onTap, - padding: EdgeInsets.zero, - style: IconButton.styleFrom( - backgroundColor: const Color(0xFF2C2D33), - foregroundColor: AppColors.onSurface, - ), - icon: Icon(icon, size: 18), - ), - ); - } -} diff --git a/lib/pages/discover/widgets/discover_search_field.dart b/lib/pages/discover/widgets/discover_search_field.dart index de549af..7688b8a 100644 --- a/lib/pages/discover/widgets/discover_search_field.dart +++ b/lib/pages/discover/widgets/discover_search_field.dart @@ -13,17 +13,19 @@ class DiscoverSearchField extends StatelessWidget { required this.focusNode, required this.onChanged, required this.loading, + this.horizontalPadding = 14, }); final TextEditingController controller; final FocusNode focusNode; final ValueChanged onChanged; final bool loading; + final double horizontalPadding; @override Widget build(BuildContext context) { return Padding( - padding: const EdgeInsets.symmetric(horizontal: 14), + padding: EdgeInsets.symmetric(horizontal: horizontalPadding), child: SizedBox( height: 36, child: TextField( diff --git a/lib/pages/discover/widgets/drama_grid.dart b/lib/pages/discover/widgets/drama_grid.dart index 7974b5d..71a3b55 100644 --- a/lib/pages/discover/widgets/drama_grid.dart +++ b/lib/pages/discover/widgets/drama_grid.dart @@ -2,9 +2,10 @@ import 'package:flutter/material.dart'; -import '../../../core/config/app_config.dart'; import '../../../core/data/models/drama.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 int _columns = 2; @@ -83,6 +84,8 @@ class DramaGridCard extends StatelessWidget { _title(), const SizedBox(height: 2), _genre(), + const SizedBox(height: AppDimens.spaceXs), + _metadata(), ], ), ); @@ -115,7 +118,7 @@ class DramaGridCard extends StatelessWidget { l10n.free, style: const TextStyle( color: AppColors.onPrimary, - fontSize: 8, + fontSize: AppDimens.cardMeta, fontWeight: FontWeight.w800, ), ), @@ -130,7 +133,7 @@ class DramaGridCard extends StatelessWidget { overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.onSurface, - fontSize: 11, + fontSize: AppDimens.previewSectionTitle, fontWeight: FontWeight.w800, ), ); @@ -143,7 +146,39 @@ class DramaGridCard extends StatelessWidget { key: Key('home-drama-genre-${drama.id}'), maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle(color: Color(0xFFBBBBBB), fontSize: 9), + style: const TextStyle( + color: AppColors.muted, + fontSize: AppDimens.cardMeta, + ), + ); + } + + Widget _metadata() { + return Wrap( + spacing: AppDimens.spaceSm, + runSpacing: AppDimens.spaceXs, + children: [ + Text( + l10n.episodeCount(drama.episodeCount), + key: Key('home-drama-episode-count-${drama.id}'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.subtle, + fontSize: AppDimens.cardViews, + ), + ), + Text( + l10n.viewCountLabel(drama.viewCount), + key: Key('home-drama-views-${drama.id}'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.subtle, + fontSize: AppDimens.cardViews, + ), + ), + ], ); } } @@ -155,33 +190,12 @@ class _DiscoverCoverImage extends StatelessWidget { @override Widget build(BuildContext context) { - final url = AppConfig.mediaUrl(path); - if (url.isEmpty) return _placeholder(); - return Image.network( - url, + return RemoteImage( + path, width: double.infinity, fit: BoxFit.fitWidth, - loadingBuilder: (context, child, progress) { - if (progress == null) return child; - return _placeholder(); - }, - errorBuilder: (_, _, _) => _placeholder(), - ); - } - - Widget _placeholder() { - return AspectRatio( - aspectRatio: 3 / 4, - child: ColoredBox( - color: AppColors.card, - child: const Center( - child: Icon( - Icons.image_not_supported_outlined, - color: AppColors.muted, - size: 32, - ), - ), - ), + showFallbackIcon: true, + fallbackAspectRatio: 3 / 4, ); } } diff --git a/lib/pages/discover/widgets/feed_banner_carousel.dart b/lib/pages/discover/widgets/feed_banner_carousel.dart index 9d9c944..8ad894d 100644 --- a/lib/pages/discover/widgets/feed_banner_carousel.dart +++ b/lib/pages/discover/widgets/feed_banner_carousel.dart @@ -5,12 +5,12 @@ import 'dart:async'; import 'package:flutter/material.dart'; import '../../../core/data/models/home_feed.dart'; +import '../../../core/data/models/drama.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 _carouselHeight = 154; const double _indicatorHeight = 24; const double _dotSize = 6; const double _activeDotWidth = 18; @@ -25,11 +25,13 @@ class FeedBannerCarousel extends StatefulWidget { required this.banners, required this.onOpen, this.active = true, + this.dramaFor, }); final List banners; final ValueChanged onOpen; final bool active; + final Drama? Function(FeedBanner banner)? dramaFor; @override State createState() => _FeedBannerCarouselState(); @@ -121,7 +123,7 @@ class _FeedBannerCarouselState extends State { child: Column( children: [ SizedBox( - height: _carouselHeight, + height: AppDimens.heroBannerHeight, child: PageView.builder( controller: _controller, itemCount: widget.banners.length, @@ -138,6 +140,7 @@ class _FeedBannerCarouselState extends State { Widget _bannerCard(int index) { final banner = widget.banners[index]; final dramaId = banner.dramaId; + final drama = widget.dramaFor?.call(banner); final card = ClipRRect( borderRadius: BorderRadius.circular(AppDimens.authCardRadius), child: Stack( @@ -149,10 +152,44 @@ class _FeedBannerCarouselState extends State { gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, - colors: [Color(0x00000000), Color(0x8A0B0C10)], + colors: [Color(0x120B0C10), Color(0xE60B0C10)], ), ), ), + if (drama != null) + Positioned( + left: 16, + right: 64, + bottom: 14, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + drama.title, + key: Key('home-hero-title-$index'), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontFamily: 'Outfit', + color: AppColors.onSurface, + fontSize: AppDimens.pageTitle, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: AppDimens.spaceXs), + Text( + drama.synopsis, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.muted, + fontSize: AppDimens.caption, + ), + ), + ], + ), + ), if (dramaId != null) const Positioned(right: 14, bottom: 14, child: _PlayMark()), ], diff --git a/lib/pages/home/home_browse_logic.dart b/lib/pages/home/home_browse_logic.dart new file mode 100644 index 0000000..5e16f7f --- /dev/null +++ b/lib/pages/home/home_browse_logic.dart @@ -0,0 +1,70 @@ +// Author: fruit.com.cn + +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +import '../../core/data/models/watch_progress.dart'; +import '../../core/data/repositories/watch_history_repository.dart'; + +/// Home browse channel, backed only by data already present in the catalogue. +enum HomeChannel { hot, free, newReleases, ranking } + +/// Local UI state for the browse-first home page. +class HomeBrowseLogic extends ChangeNotifier { + HomeBrowseLogic({required this.history}) { + unawaited(refreshRecent()); + } + + static const int recentLimit = 20; + + final WatchHistoryRepository history; + HomeChannel channel = HomeChannel.hot; + List recent = const []; + bool loadingRecent = false; + bool _disposed = false; + + /// First unfinished record with a real position. + WatchProgress? get primaryRecord { + for (final record in recent) { + if (!record.finished && record.hasPosition) return record; + } + return null; + } + + /// Changes the selected browse channel without triggering network work. + void selectChannel(HomeChannel value) { + if (channel == value) return; + channel = value; + _notify(); + } + + /// Reloads continuation data; signed-out reads simply return an empty list. + Future refreshRecent() async { + loadingRecent = true; + _notify(); + try { + recent = await history.recent(limit: recentLimit); + } catch (_) { + recent = const []; + } finally { + loadingRecent = false; + _notify(); + } + } + + /// Refreshes continuation data when the home tab becomes visible again. + void syncPage({required bool active, required bool wasActive}) { + if (active && !wasActive) unawaited(refreshRecent()); + } + + void _notify() { + if (!_disposed) notifyListeners(); + } + + @override + void dispose() { + _disposed = true; + super.dispose(); + } +} diff --git a/lib/pages/home/home_browse_view.dart b/lib/pages/home/home_browse_view.dart new file mode 100644 index 0000000..a13408d --- /dev/null +++ b/lib/pages/home/home_browse_view.dart @@ -0,0 +1,333 @@ +// 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/models/home_feed.dart'; +import '../../core/data/models/watch_progress.dart'; +import '../../core/data/repositories/watch_history_repository.dart'; +import '../../core/data/services/catalog_controller.dart'; +import '../../core/data/services/category_controller.dart'; +import '../../core/theme/app_colors.dart'; +import '../../core/theme/app_dimens.dart'; +import '../../core/widgets/empty_state.dart'; +import '../../l10n/app_localizations.dart'; +import '../discover/widgets/drama_grid.dart'; +import '../discover/widgets/feed_banner_carousel.dart'; +import 'home_browse_logic.dart'; +import 'widgets/home_browse_header.dart'; +import 'widgets/home_continue_watching.dart'; + +/// Browse-first home page with channels, hero, continuation, and catalogue. +class HomeBrowseView extends StatefulWidget { + const HomeBrowseView({ + super.key, + required this.active, + required this.onSearch, + required this.onProfile, + required this.onTheater, + required this.onHistory, + required this.onOpenDrama, + required this.onOpenContinue, + }); + + final bool active; + final VoidCallback onSearch; + final VoidCallback onProfile; + final VoidCallback onTheater; + final VoidCallback onHistory; + final Future Function(Drama drama) onOpenDrama; + final Future Function(Drama drama, WatchProgress record) onOpenContinue; + + @override + State createState() => _HomeBrowseViewState(); +} + +class _HomeBrowseViewState extends State { + late final HomeBrowseLogic logic; + + @override + void initState() { + super.initState(); + logic = HomeBrowseLogic(history: context.read()); + } + + @override + void didUpdateWidget(covariant HomeBrowseView oldWidget) { + super.didUpdateWidget(oldWidget); + logic.syncPage(active: widget.active, wasActive: oldWidget.active); + } + + @override + void dispose() { + logic.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: logic, + builder: (context, _) => _page(context), + ); + } + + Widget _page(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final categories = context.watch(); + final catalog = context.watch(); + final source = categories.feed.dramas.isEmpty + ? catalog.dramas + : categories.feed.dramas; + return SafeArea( + bottom: false, + child: Column( + children: [ + HomeBrowseHeader( + onSearch: widget.onSearch, + onProfile: widget.onProfile, + ), + Expanded(child: _body(l10n, categories, catalog, source)), + ], + ), + ); + } + + Widget _body( + AppLocalizations l10n, + CategoryController categories, + CatalogController catalog, + List source, + ) { + return RefreshIndicator( + key: const Key('home-refresh'), + color: AppColors.blue, + backgroundColor: AppColors.card, + onRefresh: () async { + await Future.wait([categories.load(), logic.refreshRecent()]); + }, + child: ListView( + key: const Key('home-browse-page'), + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.only(bottom: AppDimens.spaceXl), + children: _sections(l10n, catalog, source), + ), + ); + } + + List _sections( + AppLocalizations l10n, + CatalogController catalog, + List source, + ) { + final dramas = _channelDramas(source, logic.channel); + final continueItems = _continueItems(logic.recent, catalog.dramas); + return [ + _channels(l10n), + if (catalog.banners.isNotEmpty) _hero(catalog.banners, source), + if (continueItems.isNotEmpty) ...[ + _continueHeader(l10n), + HomeContinueWatching( + items: continueItems, + onPlay: (drama, record) => + unawaited(widget.onOpenContinue(drama, record)), + ), + ], + _channelHeader(l10n), + if (dramas.isEmpty) + _empty() + else + DramaGrid( + l10n: l10n, + dramas: dramas, + onOpen: (drama) => unawaited(widget.onOpenDrama(drama)), + ), + ]; + } + + Widget _continueHeader(AppLocalizations l10n) => _sectionHeader( + l10n.continueWatching, + actionKey: const Key('home-continue-all'), + actionLabel: l10n.seeAll, + onAction: widget.onHistory, + ); + + Widget _channelHeader(AppLocalizations l10n) => _sectionHeader( + _channelTitle(l10n), + actionKey: const Key('home-popular-more'), + actionLabel: l10n.seeAll, + onAction: widget.onTheater, + ); + + Widget _empty() => Padding( + padding: const EdgeInsets.symmetric(vertical: AppDimens.spaceXl), + child: EmptyState( + key: const Key('home-browse-empty'), + icon: Icons.movie_filter_outlined, + title: AppLocalizations.of(context)!.noStories, + subtitle: AppLocalizations.of(context)!.emptyHint, + compact: true, + ), + ); + + Widget _channels(AppLocalizations l10n) { + final entries = [ + (HomeChannel.hot, l10n.hot), + (HomeChannel.free, l10n.free), + (HomeChannel.newReleases, l10n.newDramas), + (HomeChannel.ranking, l10n.rankingTab), + ]; + return SizedBox( + height: AppDimens.buttonMedium, + child: ListView.separated( + key: const Key('home-channels'), + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric( + horizontal: AppDimens.pageHorizontal, + ), + itemCount: entries.length, + separatorBuilder: (_, _) => const SizedBox(width: AppDimens.spaceSm), + itemBuilder: (context, index) { + final entry = entries[index]; + final selected = logic.channel == entry.$1; + return ChoiceChip( + key: Key('home-channel-${entry.$1.name}'), + label: Text(entry.$2), + selected: selected, + showCheckmark: false, + onSelected: (_) => logic.selectChannel(entry.$1), + backgroundColor: AppColors.card, + selectedColor: AppColors.blue, + side: BorderSide( + color: selected ? AppColors.blue : AppColors.border, + ), + labelStyle: TextStyle( + color: selected ? AppColors.onPrimary : AppColors.muted, + fontSize: AppDimens.chipFont, + fontWeight: FontWeight.w700, + ), + padding: const EdgeInsets.symmetric(horizontal: AppDimens.spaceMd), + shape: const StadiumBorder(), + ); + }, + ), + ); + } + + Widget _hero(List banners, List dramas) { + return Padding( + padding: const EdgeInsets.only(top: AppDimens.spaceMd), + child: FeedBannerCarousel( + key: const Key('home-hero-banner'), + banners: banners, + active: widget.active, + dramaFor: (banner) => _dramaForBanner(banner, dramas), + onOpen: (banner) { + final drama = _dramaForBanner(banner, dramas); + if (drama != null) unawaited(widget.onOpenDrama(drama)); + }, + ), + ); + } + + Widget _sectionHeader( + String title, { + required Key actionKey, + required String actionLabel, + required VoidCallback onAction, + }) { + return Padding( + padding: const EdgeInsets.fromLTRB( + AppDimens.pageHorizontal, + AppDimens.spaceLg, + AppDimens.spaceSm, + AppDimens.spaceSm, + ), + child: Row( + children: [ + Expanded( + child: Text( + title, + style: const TextStyle( + fontFamily: 'Outfit', + color: AppColors.onSurface, + fontSize: AppDimens.sectionTitle, + fontWeight: FontWeight.w800, + ), + ), + ), + TextButton( + key: actionKey, + onPressed: onAction, + style: TextButton.styleFrom( + foregroundColor: AppColors.accent, + minimumSize: const Size(AppDimens.tapTarget, AppDimens.tapTarget), + ), + child: Text(actionLabel), + ), + ], + ), + ); + } + + String _channelTitle(AppLocalizations l10n) { + return switch (logic.channel) { + HomeChannel.hot => l10n.trendingMicroDramas, + HomeChannel.free => l10n.free, + HomeChannel.newReleases => l10n.newDramas, + HomeChannel.ranking => l10n.rankingTitle, + }; + } + + List _channelDramas(List source, HomeChannel channel) { + final dramas = List.of(source); + switch (channel) { + case HomeChannel.hot: + dramas.sort((a, b) { + final hot = (b.isHot ? 1 : 0).compareTo(a.isHot ? 1 : 0); + return hot != 0 ? hot : b.viewCount.compareTo(a.viewCount); + }); + case HomeChannel.free: + return dramas.where(_isFree).toList(); + case HomeChannel.newReleases: + return dramas; + case HomeChannel.ranking: + dramas.sort((a, b) => b.viewCount.compareTo(a.viewCount)); + } + return dramas; + } + + bool _isFree(Drama drama) { + return drama.freeEpisodes > 0 || + drama.episodes.any((episode) => episode.isFree); + } + + Drama? _dramaForBanner(FeedBanner banner, List dramas) { + final id = banner.dramaId; + if (id == null) return null; + for (final drama in dramas) { + if (drama.id == id) return drama; + } + return null; + } + + List _continueItems( + List records, + List dramas, + ) { + final items = []; + for (final record in records) { + if (record.finished || !record.hasPosition) continue; + for (final drama in dramas) { + if (drama.id == record.dramaId) { + items.add((drama: drama, record: record)); + break; + } + } + } + return items; + } +} diff --git a/lib/pages/home/home_logic.dart b/lib/pages/home/home_logic.dart index 8834669..f8b5110 100644 --- a/lib/pages/home/home_logic.dart +++ b/lib/pages/home/home_logic.dart @@ -69,7 +69,11 @@ class HomeLogic extends ChangeNotifier { ); } - Future playDrama(BuildContext context, Drama drama) async { + Future playDrama( + BuildContext context, + Drama drama, { + String? episodeId, + }) async { // 列表卡片只有一集预览,进入播放前补齐完整剧集,否则选集列表是空的。 var playable = drama; try { @@ -78,11 +82,14 @@ class HomeLogic extends ChangeNotifier { // 取详情失败就用卡片自带的那一集起播。 } if (!context.mounted) return; - Navigator.of(context).push( + final index = episodeId == null + ? 0 + : playable.episodes.indexWhere((episode) => episode.id == episodeId); + await Navigator.of(context).push( MaterialPageRoute( builder: (_) => PlayerPage( drama: playable, - initialIndex: 0, + initialIndex: index < 0 ? 0 : index, wallet: context.read(), downloads: context.read(), ), @@ -90,14 +97,16 @@ class HomeLogic extends ChangeNotifier { ); } - void openProfile() => select(2); + void openProfile() => select(3); void discoverAction(DiscoverPageAction action) { switch (action) { case DiscoverPageAction.home: select(0); - case DiscoverPageAction.my: + case DiscoverPageAction.create: select(2); + case DiscoverPageAction.my: + select(3); } } @@ -119,6 +128,8 @@ class HomeLogic extends ChangeNotifier { select(0); case MyPageAction.discover: select(1, search: true); + case MyPageAction.create: + select(2); case MyPageAction.resume: select(0, resume: true); } @@ -128,8 +139,8 @@ class HomeLogic extends ChangeNotifier { if (videoImmersive) { backRequest++; notify(); - } else if (tab != 1) { - select(1); + } else if (tab != 0) { + select(0); } } diff --git a/lib/pages/home/home_view.dart b/lib/pages/home/home_view.dart index 5fadb0b..c4e4e37 100644 --- a/lib/pages/home/home_view.dart +++ b/lib/pages/home/home_view.dart @@ -7,8 +7,6 @@ import 'package:provider/provider.dart'; import '../../core/data/models/drama.dart'; import '../../core/data/services/catalog_controller.dart'; -import '../../core/data/services/download_controller.dart'; -import '../../core/data/services/wallet_controller.dart'; import '../../core/platform/haptics.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_theme.dart'; @@ -17,13 +15,13 @@ import '../../core/widgets/empty_state.dart'; import '../../l10n/app_localizations.dart'; import '../discover/discover_types.dart'; import '../discover/discover_view.dart'; +import '../create/create_view.dart'; import '../mine/mine_types.dart'; import '../splash/splash_page.dart'; import '../mine/mine_view.dart'; -import '../player/player_view.dart'; import 'home_binding.dart'; +import 'home_browse_view.dart'; import 'home_logic.dart'; -import 'widgets/home_menu_sheet.dart'; class HomePage extends StatelessWidget { const HomePage({super.key}); @@ -95,7 +93,7 @@ class HomeFailureView extends StatelessWidget { } } -/// The three primary destinations share a single route and keep their state. +/// The four primary destinations share a single route and keep their state. class _HomeTabs extends StatefulWidget { const _HomeTabs({super.key, required this.dramas}); @@ -134,11 +132,7 @@ class _HomeTabsState extends State<_HomeTabs> { canPop: false, onPopInvokedWithResult: (didPop, _) { if (didPop) return; - if (!logic.videoImmersive && logic.tab == 1) { - unawaited(_openMenu(context)); - } else { - logic.onSystemBack(); - } + logic.onSystemBack(); }, child: Scaffold( key: logic.scaffoldKey, @@ -152,28 +146,25 @@ class _HomeTabsState extends State<_HomeTabs> { Widget _body(BuildContext context) { return IndexedStack( index: logic.tab, - children: [_player(context), _discover(context), _my(context)], + children: [ + _home(context), + _discover(context), + const CreatePage(), + _my(context), + ], ); } - Widget _player(BuildContext context) { - return PlayerPage( - drama: widget.dramas.first, - feedDramas: widget.dramas, - onDramaChanged: logic.onDramaChanged, - initialIndex: 0, - homeMode: true, - embedded: true, - active: logic.tab == 0 && !logic.drawerOpen && !logic.detailOpen, - resumeRequest: logic.resumeRequest, - backRequest: logic.backRequest, - onDiscover: () => logic.select(1), - onMy: logic.openProfile, + Widget _home(BuildContext context) { + return HomeBrowseView( + active: logic.tab == 0, onSearch: () => logic.select(1, search: true), - onMenu: () => _openMenu(context), - onImmersiveChanged: logic.onImmersiveChanged, - wallet: context.read(), - downloads: context.read(), + onProfile: logic.openProfile, + onTheater: () => logic.select(1), + onHistory: () => logic.openHistory(context), + onOpenDrama: (drama) => logic.playDrama(context, drama), + onOpenContinue: (drama, record) => + logic.playDrama(context, drama, episodeId: record.episodeId), ); } @@ -191,7 +182,7 @@ class _HomeTabsState extends State<_HomeTabs> { Widget _my(BuildContext context) { return MyPage( - active: logic.tab == 2, + active: logic.tab == 3, callbacks: MyPageCallbacks( onAction: logic.myAction, onDramaSelected: (drama) => logic.openDrama(context, drama), @@ -206,6 +197,7 @@ class _HomeTabsState extends State<_HomeTabs> { _navItem(context, l10n, 0), _navItem(context, l10n, 1), _navItem(context, l10n, 2), + _navItem(context, l10n, 3), ], ); } @@ -219,24 +211,32 @@ class _HomeTabsState extends State<_HomeTabs> { return switch (index) { 0 => AppBottomNavItem( tabKey: const Key('tab-home'), - icon: Icons.play_circle_outline_rounded, - selectedIcon: Icons.play_circle_fill_rounded, + icon: Icons.home_outlined, + selectedIcon: Icons.home_rounded, label: l10n.homeTab, selected: selected, onTap: () => _selectTab(context, index), ), 1 => AppBottomNavItem( - tabKey: const Key('tab-discover'), + tabKey: const Key('tab-theater'), icon: Icons.explore_outlined, selectedIcon: Icons.explore_rounded, - label: l10n.discoverTab, + label: l10n.theaterTab, + selected: selected, + onTap: () => _selectTab(context, index), + ), + 2 => AppBottomNavItem( + tabKey: const Key('tab-create'), + icon: Icons.add_box_outlined, + selectedIcon: Icons.add_box_rounded, + label: l10n.createTab, selected: selected, onTap: () => _selectTab(context, index), ), _ => AppBottomNavItem( - tabKey: const Key('tab-my'), - icon: Icons.video_library_outlined, - selectedIcon: Icons.video_library_rounded, + tabKey: const Key('tab-mine'), + icon: Icons.person_outline_rounded, + selectedIcon: Icons.person_rounded, label: l10n.myTab, selected: selected, onTap: () => _selectTab(context, index), @@ -251,22 +251,4 @@ class _HomeTabsState extends State<_HomeTabs> { } logic.select(tab); } - - Future _openMenu(BuildContext context) async { - logic.setMenuOpen(true); - await showModalBottomSheet( - context: context, - backgroundColor: AppColors.card, - showDragHandle: true, - isScrollControlled: true, - builder: (_) => HomeMenuSheet( - drama: logic.currentDrama, - onOpenSeries: () => logic.openCurrentSeries(context), - onDiscover: () => logic.select(1), - onHistory: () => logic.openHistory(context), - onWatchlist: () => logic.openFavorites(context), - ), - ); - logic.setMenuOpen(false); - } } diff --git a/lib/pages/home/widgets/home_browse_header.dart b/lib/pages/home/widgets/home_browse_header.dart new file mode 100644 index 0000000..91406db --- /dev/null +++ b/lib/pages/home/widgets/home_browse_header.dart @@ -0,0 +1,134 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_dimens.dart'; +import '../../../l10n/app_localizations.dart'; + +const double _searchHeight = 36; + +/// Fixed home header with catalogue search, weekday, and profile entry. +class HomeBrowseHeader extends StatelessWidget { + const HomeBrowseHeader({ + super.key, + required this.onSearch, + required this.onProfile, + }); + + final VoidCallback onSearch; + final VoidCallback onProfile; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final weekday = _weekday(l10n); + return SizedBox( + height: AppDimens.contentHeaderHeight, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppDimens.pageHorizontal, + ), + child: Row( + children: [ + Expanded(child: _search(l10n, onSearch)), + const SizedBox(width: AppDimens.spaceSm), + _weekdayText(weekday), + const SizedBox(width: AppDimens.spaceSm), + _profile(l10n), + ], + ), + ), + ); + } + + String _weekday(AppLocalizations l10n) { + return switch (DateTime.now().weekday) { + DateTime.monday => l10n.weekdayMon, + DateTime.tuesday => l10n.weekdayTue, + DateTime.wednesday => l10n.weekdayWed, + DateTime.thursday => l10n.weekdayThu, + DateTime.friday => l10n.weekdayFri, + DateTime.saturday => l10n.weekdaySat, + _ => l10n.weekdaySun, + }; + } + + Widget _search(AppLocalizations l10n, VoidCallback onTap) { + return Semantics( + button: true, + label: l10n.search, + child: InkWell( + key: const Key('home-search'), + onTap: onTap, + borderRadius: BorderRadius.circular(AppDimens.pillRadius), + child: Container( + height: _searchHeight, + padding: const EdgeInsets.symmetric(horizontal: AppDimens.spaceMd), + decoration: BoxDecoration( + color: AppColors.input, + borderRadius: BorderRadius.circular(AppDimens.pillRadius), + ), + child: Row( + children: [ + const Icon( + Icons.search_rounded, + color: AppColors.subtle, + size: 17, + ), + const SizedBox(width: AppDimens.spaceSm), + Expanded( + child: Text( + l10n.searchDiscover, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.subtle, + fontSize: AppDimens.caption, + ), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _weekdayText(String value) { + return Text( + value, + key: const Key('home-weekday'), + style: const TextStyle( + color: AppColors.muted, + fontSize: AppDimens.caption, + fontWeight: FontWeight.w600, + ), + ); + } + + Widget _profile(AppLocalizations l10n) { + return Semantics( + button: true, + label: l10n.myTab, + child: InkWell( + key: const Key('home-profile'), + customBorder: const CircleBorder(), + onTap: onProfile, + child: Container( + width: AppDimens.tapTarget, + height: AppDimens.tapTarget, + decoration: const BoxDecoration( + color: AppColors.card, + shape: BoxShape.circle, + ), + child: const Icon( + Icons.person_rounded, + color: AppColors.onSurface, + size: AppDimens.accountIcon, + ), + ), + ), + ); + } +} diff --git a/lib/pages/home/widgets/home_continue_watching.dart b/lib/pages/home/widgets/home_continue_watching.dart new file mode 100644 index 0000000..10544cb --- /dev/null +++ b/lib/pages/home/widgets/home_continue_watching.dart @@ -0,0 +1,147 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../../../core/data/models/drama.dart'; +import '../../../core/data/models/watch_progress.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 _railHeight = 96; +const double _cardWidth = 286; +const double _coverWidth = 64; +const double _playSize = 34; + +/// One drama/record pair rendered by the continuation rail. +typedef HomeContinueItem = ({Drama drama, WatchProgress record}); + +/// Home continuation cards for records that still have unwatched time. +class HomeContinueWatching extends StatelessWidget { + const HomeContinueWatching({ + super.key, + required this.items, + required this.onPlay, + }); + + final List items; + final void Function(Drama drama, WatchProgress record) onPlay; + + @override + Widget build(BuildContext context) { + if (items.isEmpty) return const SizedBox.shrink(); + return SizedBox( + key: const Key('home-continue-watching'), + height: _railHeight, + child: ListView.separated( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric( + horizontal: AppDimens.pageHorizontal, + ), + itemCount: items.length, + separatorBuilder: (_, _) => const SizedBox(width: AppDimens.spaceSm), + itemBuilder: (context, index) { + final item = items[index]; + return _ContinueCard( + item: item, + onPlay: () => onPlay(item.drama, item.record), + ); + }, + ), + ); + } +} + +class _ContinueCard extends StatelessWidget { + const _ContinueCard({required this.item, required this.onPlay}); + + final HomeContinueItem item; + final VoidCallback onPlay; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return Container( + width: _cardWidth, + padding: const EdgeInsets.all(AppDimens.spaceSm), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppDimens.cardRadius), + ), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(AppDimens.innerRadius), + child: RemoteImage( + item.drama.posterUrl, + width: _coverWidth, + height: _railHeight - AppDimens.spaceLg, + ), + ), + const SizedBox(width: AppDimens.spaceSm), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.drama.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: AppDimens.previewCardTitle, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: AppDimens.spaceXs), + Text( + _progressText(l10n, item.drama, item.record), + key: Key('home-continue-progress-${item.drama.id}'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.muted, + fontSize: AppDimens.cardMeta, + ), + ), + ], + ), + ), + const SizedBox(width: AppDimens.spaceSm), + IconButton( + key: Key('home-continue-play-${item.drama.id}'), + onPressed: onPlay, + style: IconButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: AppColors.onPrimary, + fixedSize: const Size.square(_playSize), + padding: EdgeInsets.zero, + ), + icon: const Icon( + Icons.play_arrow_rounded, + size: AppDimens.accountIcon, + ), + ), + ], + ), + ); + } + + String _progressText( + AppLocalizations l10n, + Drama drama, + WatchProgress record, + ) { + final episode = drama.episodes.where( + (value) => value.id == record.episodeId, + ); + final number = episode.isEmpty ? 1 : episode.first.number; + final remaining = record.duration - record.position; + final minutes = remaining.inSeconds <= 0 + ? 0 + : (remaining.inSeconds / Duration.secondsPerMinute).ceil(); + return l10n.episodeRemaining(number, minutes); + } +} diff --git a/lib/pages/home/widgets/home_menu_sheet.dart b/lib/pages/home/widgets/home_menu_sheet.dart index 0a255b3..80b4f3a 100644 --- a/lib/pages/home/widgets/home_menu_sheet.dart +++ b/lib/pages/home/widgets/home_menu_sheet.dart @@ -74,7 +74,7 @@ class HomeMenuSheet extends StatelessWidget { _item( key: const Key('drawer-discover'), icon: Icons.explore_outlined, - label: l10n.discoverTab, + label: l10n.theaterTab, onTap: () => closeThen(onDiscover), ), _item( diff --git a/lib/pages/mine/mine_types.dart b/lib/pages/mine/mine_types.dart index 192bd47..51783b3 100644 --- a/lib/pages/mine/mine_types.dart +++ b/lib/pages/mine/mine_types.dart @@ -5,7 +5,7 @@ import 'package:flutter/foundation.dart'; import '../../core/data/models/drama.dart'; /// 「我的」页与根导航之间的返回动作。 -enum MyPageAction { home, discover, resume } +enum MyPageAction { home, discover, create, resume } /// 「我的」页导航回调;没有 `onAction` 时按独立页面处理。 class MyPageCallbacks { diff --git a/lib/pages/mine/mine_view.dart b/lib/pages/mine/mine_view.dart index 92e41bb..0beee47 100644 --- a/lib/pages/mine/mine_view.dart +++ b/lib/pages/mine/mine_view.dart @@ -245,8 +245,8 @@ class _MyPageState extends State { items: [ AppBottomNavItem( tabKey: const Key('mine-nav-home'), - icon: Icons.play_circle_outline_rounded, - selectedIcon: Icons.play_circle_fill_rounded, + icon: Icons.home_outlined, + selectedIcon: Icons.home_rounded, label: l10n.homeTab, onTap: () => _act(MyPageAction.home), ), @@ -254,9 +254,16 @@ class _MyPageState extends State { tabKey: const Key('mine-nav-discover'), icon: Icons.explore_outlined, selectedIcon: Icons.explore_rounded, - label: l10n.discoverTab, + label: l10n.theaterTab, onTap: () => _act(MyPageAction.discover), ), + AppBottomNavItem( + tabKey: const Key('mine-nav-create'), + icon: Icons.add_box_outlined, + selectedIcon: Icons.add_box_rounded, + label: l10n.createTab, + onTap: () => _act(MyPageAction.create), + ), AppBottomNavItem( tabKey: const Key('mine-nav-current'), icon: Icons.video_library_outlined, diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 986f88c..2f462d3 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -944,43 +944,19 @@ class _PlaybackOverlayState extends State<_PlaybackOverlay> { backgroundColor: AppColors.card, showDragHandle: true, builder: (sheetContext) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - key: const Key('player-more-speed'), - leading: const Icon( - Icons.speed_rounded, - color: AppColors.secondary, - ), - title: Text(widget.l10n.playbackSpeed), - trailing: Text( - _speedLabel(widget.playbackSpeed), - style: const TextStyle( - color: AppColors.blue, - fontSize: 12, - fontWeight: FontWeight.w600, - ), - ), - onTap: () { - Navigator.pop(sheetContext); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) widget.onSpeedTap(); - }); - }, - ), - if (widget.qualities.isNotEmpty) + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ ListTile( - key: const Key('player-more-quality'), + key: const Key('player-more-speed'), leading: const Icon( - Icons.high_quality_rounded, + Icons.speed_rounded, color: AppColors.secondary, ), - title: Text(widget.l10n.playbackQuality), + title: Text(widget.l10n.playbackSpeed), trailing: Text( - widget.qualities.contains(QualitySheet.autoQuality) - ? widget.l10n.qualityAuto - : widget.qualities.first, + _speedLabel(widget.playbackSpeed), style: const TextStyle( color: AppColors.blue, fontSize: 12, @@ -989,76 +965,105 @@ class _PlaybackOverlayState extends State<_PlaybackOverlay> { ), onTap: () { Navigator.pop(sheetContext); - widget.onQualityTap(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) widget.onSpeedTap(); + }); }, ), - ListTile( - key: const Key('player-more-download'), - leading: const Icon( - Icons.download_outlined, - color: AppColors.secondary, - ), - title: Text(widget.l10n.downloads), - onTap: () { - Navigator.pop(sheetContext); - widget.onDownload(); - }, - ), - if (widget.onPip != null) + if (widget.qualities.isNotEmpty) + ListTile( + key: const Key('player-more-quality'), + leading: const Icon( + Icons.high_quality_rounded, + color: AppColors.secondary, + ), + title: Text(widget.l10n.playbackQuality), + trailing: Text( + widget.qualities.contains(QualitySheet.autoQuality) + ? widget.l10n.qualityAuto + : widget.qualities.first, + style: const TextStyle( + color: AppColors.blue, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + onTap: () { + Navigator.pop(sheetContext); + widget.onQualityTap(); + }, + ), ListTile( - key: const Key('player-more-pip'), + key: const Key('player-more-download'), leading: const Icon( - Icons.picture_in_picture_alt_outlined, + Icons.download_outlined, color: AppColors.secondary, ), - title: Text(widget.l10n.pictureInPicture), + title: Text(widget.l10n.downloads), onTap: () { Navigator.pop(sheetContext); - widget.onPip!(); + widget.onDownload(); }, ), - ListTile( - key: const Key('player-more-like'), - leading: Icon( - widget.liked - ? Icons.favorite_rounded - : Icons.favorite_border_rounded, - color: widget.liked ? AppColors.blue : AppColors.muted, + if (widget.onPip != null) + ListTile( + key: const Key('player-more-pip'), + leading: const Icon( + Icons.picture_in_picture_alt_outlined, + color: AppColors.secondary, + ), + title: Text(widget.l10n.pictureInPicture), + onTap: () { + Navigator.pop(sheetContext); + widget.onPip!(); + }, + ), + ListTile( + key: const Key('player-more-like'), + leading: Icon( + widget.liked + ? Icons.favorite_rounded + : Icons.favorite_border_rounded, + color: widget.liked ? AppColors.blue : AppColors.muted, + ), + title: Text(widget.l10n.like), + trailing: widget.likeCount == null + ? null + : Text( + '${widget.likeCount}', + key: const Key('player-more-like-count'), + ), + onTap: () { + Navigator.pop(sheetContext); + widget.onLike(); + }, ), - title: Text(widget.l10n.like), - trailing: widget.likeCount == null - ? null - : Text( - '${widget.likeCount}', - key: const Key('player-more-like-count'), - ), - onTap: () { - Navigator.pop(sheetContext); - widget.onLike(); - }, - ), - ListTile( - key: const Key('player-more-favorite'), - leading: Icon( - widget.saved ? Icons.star_rounded : Icons.star_border_rounded, - color: widget.saved ? AppColors.blue : AppColors.muted, + ListTile( + key: const Key('player-more-favorite'), + leading: Icon( + widget.saved ? Icons.star_rounded : Icons.star_border_rounded, + color: widget.saved ? AppColors.blue : AppColors.muted, + ), + title: Text(widget.l10n.favorite), + onTap: () { + Navigator.pop(sheetContext); + widget.onSave(); + }, ), - title: Text(widget.l10n.favorite), - onTap: () { - Navigator.pop(sheetContext); - widget.onSave(); - }, - ), - ListTile( - key: const Key('player-more-share'), - leading: const Icon(Icons.share_outlined, color: AppColors.muted), - title: Text(widget.l10n.share), - onTap: () { - Navigator.pop(sheetContext); - widget.onShare(); - }, - ), - ], + ListTile( + key: const Key('player-more-share'), + leading: const Icon( + Icons.share_outlined, + color: AppColors.muted, + ), + title: Text(widget.l10n.share), + onTap: () { + Navigator.pop(sheetContext); + widget.onShare(); + }, + ), + ], + ), ), ), ); diff --git a/test/account/mine_account_entry_test.dart b/test/account/mine_account_entry_test.dart index 8cfad42..305292c 100644 --- a/test/account/mine_account_entry_test.dart +++ b/test/account/mine_account_entry_test.dart @@ -24,7 +24,7 @@ void main() { InMemorySharedPreferencesAsync.empty(); await _pumpApp(tester); - await tester.tap(find.byKey(const Key('tab-my'))); + await tester.tap(find.byKey(const Key('tab-mine'))); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('mine-settings'))); await tester.pumpAndSettle(); @@ -47,7 +47,7 @@ void main() { ); await _pumpApp(tester); - await tester.tap(find.byKey(const Key('tab-my'))); + await tester.tap(find.byKey(const Key('tab-mine'))); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('mine-settings'))); await tester.pumpAndSettle(); diff --git a/test/discover/discover_banner_test.dart b/test/discover/discover_banner_test.dart index 991e7c7..1a441c5 100644 --- a/test/discover/discover_banner_test.dart +++ b/test/discover/discover_banner_test.dart @@ -7,7 +7,7 @@ import 'package:fruitshorts/core/data/models/home_feed.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/pages/drama_detail/drama_detail_view.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'; @@ -23,7 +23,7 @@ void main() { InMemorySharedPreferencesAsync.withData({}); }); - testWidgets('should_open_linked_drama_when_discover_banner_is_tapped', ( + testWidgets('should_open_fullscreen_player_when_home_hero_is_tapped', ( tester, ) async { final catalog = FakeCatalogRepository( @@ -37,15 +37,13 @@ void main() { ], ); await _pumpApp(tester, catalog); - await tester.tap(find.byKey(const Key('tab-discover'))); - await tester.pumpAndSettle(); - expect(find.byKey(const Key('feed-banner-carousel')), findsOneWidget); await tester.tap(find.byKey(const Key('feed-banner-0'))); await tester.pumpAndSettle(); - expect(find.byType(DramaDetailPage), findsOneWidget); - expect(find.text('The Signal'), findsWidgets); + final player = tester.widget(find.byType(PlayerPage)); + expect(player.homeMode, isFalse); + expect(player.drama.title, 'The Signal'); }); } diff --git a/test/discover/home_all_dramas_grid_test.dart b/test/discover/home_all_dramas_grid_test.dart index 8b5c736..81b001b 100644 --- a/test/discover/home_all_dramas_grid_test.dart +++ b/test/discover/home_all_dramas_grid_test.dart @@ -24,37 +24,27 @@ void main() { InMemorySharedPreferencesAsync.empty(); }); - testWidgets('should_show_search_categories_ranking_and_all_dramas', ( + testWidgets('should_show_search_category_language_and_drama_grid', ( tester, ) async { await _pumpTheater(tester, categoryRepository: _GridCategoryRepository()); expect(find.text('Continue Watching'), findsNothing); expect(find.byKey(const Key('discover-search')), findsOneWidget); - expect(find.byKey(const Key('discover-category-grid')), findsOneWidget); - expect(find.byKey(const Key('discover-ranking-section')), findsOneWidget); + expect(find.byKey(const Key('theater-category-chips')), findsOneWidget); + expect(find.byKey(const Key('theater-language-chips')), findsOneWidget); expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); - expect(find.byKey(const Key('ranking-tab-hot')), findsOneWidget); - expect(find.byKey(const Key('ranking-tab-new')), findsOneWidget); - - final grid = tester.widget( - find.byKey(const Key('discover-category-grid')), - ); - final delegate = - grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount; - expect(delegate.crossAxisCount, 3); expect(tester.takeException(), isNull); }); - testWidgets('should_open_category_dramas_page_when_tile_is_tapped', ( + testWidgets('should_filter_grid_when_category_chip_is_tapped', ( tester, ) async { await _pumpTheater(tester, categoryRepository: _GridCategoryRepository()); - await tester.tap(find.byKey(const Key('discover-category-7'))); + await tester.tap(find.byKey(const Key('theater-category-7'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('category-dramas-page')), findsOneWidget); expect( find.byKey(const Key('home-drama-card-city-lights-demo')), findsOneWidget, @@ -76,22 +66,23 @@ void main() { ); final headerBefore = tester.getTopLeft( - find.byKey(const Key('discover-search-button')), + find.byKey(const Key('discover-search')), ); - final contentBefore = tester.getTopLeft( - find.byKey(const Key('discover-category-grid')), + final scrollable = find.descendant( + of: find.byKey(const Key('discover-refresh')), + matching: find.byType(Scrollable), ); await tester.drag(find.byType(ListView), const Offset(0, -140)); await tester.pumpAndSettle(); expect( - tester.getTopLeft(find.byKey(const Key('discover-search-button'))), + tester.getTopLeft(find.byKey(const Key('discover-search'))), headerBefore, ); expect( - tester.getTopLeft(find.byKey(const Key('discover-category-grid'))).dy, - lessThan(contentBefore.dy), + tester.state(scrollable).position.pixels, + greaterThan(0), ); }); @@ -172,7 +163,7 @@ Future _pumpTheater( ), ); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('tab-discover'))); + await tester.tap(find.byKey(const Key('tab-theater'))); await tester.pumpAndSettle(); } diff --git a/test/home/home_logic_test.dart b/test/home/home_logic_test.dart index 54ff50f..f9c2427 100644 --- a/test/home/home_logic_test.dart +++ b/test/home/home_logic_test.dart @@ -66,22 +66,20 @@ void main() { logic.openProfile(); - expect(logic.tab, 2); + expect(logic.tab, 3); logic.dispose(); }); - test('should_return_to_discover_and_handle_immersive_back', () { + test('should_return_home_and_handle_immersive_back', () { final logic = HomeLogic(dramas); logic.onSystemBack(); - expect(logic.tab, 1); - logic.onSystemBack(); - expect(logic.tab, 1); + expect(logic.tab, 0); expect(logic.backRequest, 0); - logic.select(0); + logic.select(1); logic.onSystemBack(); - expect(logic.tab, 1); + expect(logic.tab, 0); logic.select(0); logic.onImmersiveChanged(true); @@ -91,7 +89,7 @@ void main() { logic.onImmersiveChanged(false); logic.onSystemBack(); - expect(logic.tab, 1); + expect(logic.tab, 0); logic.dispose(); }); @@ -130,6 +128,9 @@ void main() { expect(logic.tab, 1); expect(logic.searchRequest, 1); + logic.myAction(MyPageAction.create); + expect(logic.tab, 2); + logic.myAction(MyPageAction.resume); expect(logic.tab, 0); expect(logic.resumeRequest, 1); @@ -144,6 +145,9 @@ void main() { final logic = HomeLogic(dramas); logic.discoverAction(DiscoverPageAction.my); + expect(logic.tab, 3); + + logic.discoverAction(DiscoverPageAction.create); expect(logic.tab, 2); logic.discoverAction(DiscoverPageAction.home); diff --git a/test/home/home_nav_ui_test.dart b/test/home/home_nav_ui_test.dart index 65db607..d5f2563 100644 --- a/test/home/home_nav_ui_test.dart +++ b/test/home/home_nav_ui_test.dart @@ -19,8 +19,9 @@ import '../support/widget_player.dart'; const _keys = [ Key('home-tabs-bar'), Key('tab-home'), - Key('tab-discover'), - Key('tab-my'), + Key('tab-theater'), + Key('tab-create'), + Key('tab-mine'), ]; void main() { @@ -33,7 +34,7 @@ void main() { }); }); - testWidgets('should_theme_three_tabs_and_switch_selected_state_when_tapped', ( + testWidgets('should_theme_four_tabs_and_switch_selected_state_when_tapped', ( tester, ) async { final haptics = FakeHaptics(); @@ -68,20 +69,20 @@ void main() { expect( find.descendant( of: find.byKey(const Key('tab-home')), - matching: find.byIcon(Icons.play_circle_fill_rounded), + matching: find.byIcon(Icons.home_rounded), ), findsOneWidget, ); - await tester.tap(find.byKey(const Key('tab-discover'))); + await tester.tap(find.byKey(const Key('tab-theater'))); await tester.pumpAndSettle(); expect(haptics.selectionClicks, 1); - await tester.tap(find.byKey(const Key('tab-discover'))); + await tester.tap(find.byKey(const Key('tab-theater'))); await tester.pumpAndSettle(); expect(haptics.selectionClicks, 1); expect( find.descendant( - of: find.byKey(const Key('tab-discover')), + of: find.byKey(const Key('tab-theater')), matching: find.byIcon(Icons.explore_rounded), ), findsOneWidget, @@ -89,25 +90,32 @@ void main() { expect( find.descendant( of: find.byKey(const Key('tab-home')), - matching: find.byIcon(Icons.play_circle_outline_rounded), + matching: find.byIcon(Icons.home_outlined), ), findsOneWidget, ); }); - testWidgets('should_map_three_tabs_to_home_discover_and_me', (tester) async { + testWidgets('should_map_four_tabs_to_home_theater_create_and_me', ( + tester, + ) async { await _pumpApp(tester); expect(find.byKey(const Key('tab-home')), findsOneWidget); - expect(find.byKey(const Key('tab-discover')), findsOneWidget); - expect(find.byKey(const Key('tab-my')), findsOneWidget); - expect(find.byKey(const Key('home-feed-info-card')), findsOneWidget); + expect(find.byKey(const Key('tab-theater')), findsOneWidget); + expect(find.byKey(const Key('tab-create')), findsOneWidget); + expect(find.byKey(const Key('tab-mine')), findsOneWidget); + expect(find.byKey(const Key('home-browse-page')), findsOneWidget); - await tester.tap(find.byKey(const Key('tab-discover'))); + await tester.tap(find.byKey(const Key('tab-theater'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); - await tester.tap(find.byKey(const Key('tab-my'))); + await tester.tap(find.byKey(const Key('tab-create'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('create-page')), findsOneWidget); + + await tester.tap(find.byKey(const Key('tab-mine'))); await tester.pumpAndSettle(); expect(find.text('Viewer'), findsOneWidget); expect(find.byKey(const Key('mine-menu')), findsOneWidget); @@ -116,7 +124,7 @@ void main() { await tester.tap(find.byKey(const Key('tab-home'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('home-feed-info-card')), findsOneWidget); + expect(find.byKey(const Key('home-browse-page')), findsOneWidget); }); testWidgets('should_not_haptic_when_ordinary_list_item_is_opened', ( @@ -124,7 +132,7 @@ void main() { ) async { final haptics = FakeHaptics(); await _pumpApp(tester, haptics); - await tester.tap(find.byKey(const Key('tab-my'))); + await tester.tap(find.byKey(const Key('tab-mine'))); await tester.pumpAndSettle(); haptics.reset(); diff --git a/test/home/home_preview_layout_test.dart b/test/home/home_preview_layout_test.dart index 82badc6..dccae62 100644 --- a/test/home/home_preview_layout_test.dart +++ b/test/home/home_preview_layout_test.dart @@ -7,6 +7,7 @@ import 'package:shared_preferences_platform_interface/shared_preferences_async_p 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/data/repositories/favorite_repository.dart'; import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; @@ -22,7 +23,7 @@ import '../support/fake_notice_repository.dart'; import '../support/widget_player.dart'; void main() { - testWidgets('should_render_preview_home_card_without_hot_or_follow_avatar', ( + testWidgets('should_render_browse_home_with_header_channels_and_cards', ( tester, ) async { SharedPreferencesAsyncPlatform.instance = @@ -32,7 +33,16 @@ void main() { addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); - final catalog = FakeCatalogRepository(); + final catalog = FakeCatalogRepository( + banners: const [ + FeedBanner( + id: 'banner-1', + imageUrl: '/files/banner.jpg', + linkType: 'DRAMA', + linkValue: 'the-signal-demo', + ), + ], + ); await tester.pumpWidget( LumaDramaApp( playerFactory: createWidgetPlayer, @@ -51,40 +61,18 @@ void main() { await tester.tap(find.byKey(const Key('tab-home'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('home-feed-title')), findsOneWidget); - expect(find.byKey(const Key('home-feed-info-card')), findsOneWidget); - expect(find.byKey(const Key('home-feed-episode')), findsOneWidget); - final cardRect = tester.getRect( - find.byKey(const Key('home-feed-info-card')), - ); - final episodeRect = tester.getRect( - find.byKey(const Key('home-feed-episode')), - ); - expect(episodeRect.left, lessThan(cardRect.center.dx)); - final railCenter = tester.getCenter(find.byKey(const Key('rail-like'))); - final railBottom = tester.getBottomLeft(find.byKey(const Key('rail-save'))); - expect(railCenter.dy, greaterThan(844 * 0.4)); - expect(railBottom.dy, lessThan(cardRect.top)); - expect(find.byKey(const Key('home-feed-follow-series')), findsOneWidget); - expect(find.byKey(const Key('home-feed-episodes')), findsOneWidget); - for (final key in const [Key('home-feed-search'), Key('home-feed-more')]) { - expect(tester.getSize(find.byKey(key)), const Size(36, 36)); - expect( - tester - .widget( - find.descendant(of: find.byKey(key), matching: find.byType(Icon)), - ) - .size, - 18, - ); + expect(find.byKey(const Key('home-search')), findsOneWidget); + expect(find.byKey(const Key('home-profile')), findsOneWidget); + expect(find.byKey(const Key('home-weekday')), findsOneWidget); + for (final channel in ['hot', 'free', 'newReleases', 'ranking']) { + expect(find.byKey(Key('home-channel-$channel')), findsOneWidget); } + expect(find.byKey(const Key('home-hero-banner')), findsOneWidget); expect( - tester.getSize(find.byKey(const Key('home-feed-follow-series'))).height, - 34, + find.byKey(const Key('home-drama-card-the-signal-demo')), + findsOneWidget, ); - expect(find.text('HOT'), findsNothing); - expect(find.textContaining('Season'), findsNothing); - expect(find.byKey(const Key('home-feed-follow')), findsNothing); + expect(find.byKey(const Key('home-feed-info-card')), findsNothing); expect(tester.takeException(), isNull); }); diff --git a/test/home/k28_a_navigation_test.dart b/test/home/k28_a_navigation_test.dart new file mode 100644 index 0000000..cc142da --- /dev/null +++ b/test/home/k28_a_navigation_test.dart @@ -0,0 +1,206 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/app.dart'; +import 'package:fruitshorts/core/data/models/home_feed.dart'; +import 'package:fruitshorts/core/data/models/watch_progress.dart'; +import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; +import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; +import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; +import 'package:fruitshorts/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_four_tabs_and_open_create_placeholder', ( + tester, + ) async { + await _pumpApp(tester); + + for (final key in _tabKeys) { + expect(find.byKey(key), findsOneWidget); + } + + await tester.tap(find.byKey(const Key('tab-create'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('create-page')), findsOneWidget); + expect(find.text('Create'), findsWidgets); + expect(find.text('Stories become short dramas'), findsOneWidget); + }); + + testWidgets('should_switch_home_channel_when_chip_is_tapped', (tester) async { + await _pumpApp(tester); + + expect(_chip(tester, 'home-channel-hot').selected, isTrue); + expect(_chip(tester, 'home-channel-free').selected, isFalse); + + await tester.tap(find.byKey(const Key('home-channel-free'))); + await tester.pumpAndSettle(); + + expect(_chip(tester, 'home-channel-hot').selected, isFalse); + expect(_chip(tester, 'home-channel-free').selected, isTrue); + }); + + testWidgets('should_open_theater_search_when_home_search_is_tapped', ( + tester, + ) async { + await _pumpApp(tester); + + await tester.tap(find.byKey(const Key('home-search'))); + await tester.pumpAndSettle(); + + final field = tester.widget( + find.byKey(const Key('discover-search')), + ); + expect(field.focusNode?.hasFocus, isTrue); + }); + + testWidgets('should_select_theater_language_without_filtering_catalog', ( + tester, + ) async { + await _pumpApp(tester); + await tester.tap(find.byKey(const Key('tab-theater'))); + await tester.pumpAndSettle(); + + expect(_chip(tester, 'theater-language-all').selected, isTrue); + expect( + find.byKey(const Key('home-drama-card-the-signal-demo')), + findsOneWidget, + ); + + await tester.tap(find.byKey(const Key('theater-language-id'))); + await tester.pumpAndSettle(); + + expect(_chip(tester, 'theater-language-all').selected, isFalse); + expect(_chip(tester, 'theater-language-id').selected, isTrue); + expect( + find.byKey(const Key('home-drama-card-the-signal-demo')), + findsOneWidget, + ); + }); + + testWidgets('should_open_fullscreen_player_when_home_card_is_tapped', ( + tester, + ) async { + await _pumpApp(tester); + + await tester.tap(find.byKey(const Key('home-drama-card-the-signal-demo'))); + await tester.pumpAndSettle(); + + final player = tester.widget(find.byType(PlayerPage)); + expect(player.homeMode, isFalse); + }); + + testWidgets('should_open_fullscreen_player_when_theater_card_is_tapped', ( + tester, + ) async { + await _pumpApp(tester); + await tester.tap(find.byKey(const Key('tab-theater'))); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('home-drama-card-the-signal-demo'))); + await tester.pumpAndSettle(); + + final player = tester.widget(find.byType(PlayerPage)); + expect(player.homeMode, isFalse); + }); + + testWidgets('should_resume_continue_watching_at_saved_episode', ( + tester, + ) async { + await _pumpApp( + tester, + history: InMemoryWatchHistoryRepository( + seed: const [ + WatchProgress( + dramaId: 'the-signal-demo', + episodeId: 'the-signal-demo-3', + position: Duration(minutes: 2), + duration: Duration(minutes: 10), + ), + ], + ), + ); + + expect(find.byKey(const Key('home-continue-watching')), findsOneWidget); + await tester.tap( + find.byKey(const Key('home-continue-play-the-signal-demo')), + ); + await tester.pumpAndSettle(); + + final player = tester.widget(find.byType(PlayerPage)); + expect(player.homeMode, isFalse); + expect(player.initialIndex, 2); + }); + + testWidgets('should_fit_home_and_theater_on_320_width', (tester) async { + await _pumpApp(tester, size: const Size(320, 568)); + + expect(find.byKey(const Key('home-search')), findsOneWidget); + expect(find.byKey(const Key('home-hero-title-0')), findsOneWidget); + expect(tester.takeException(), isNull); + + await tester.tap(find.byKey(const Key('tab-theater'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('theater-language-chips')), findsOneWidget); + expect(tester.takeException(), isNull); + }); +} + +const _tabKeys = [ + Key('tab-home'), + Key('tab-theater'), + Key('tab-create'), + Key('tab-mine'), +]; + +ChoiceChip _chip(WidgetTester tester, String key) => + tester.widget(find.byKey(Key(key))); + +Future _pumpApp( + WidgetTester tester, { + WatchHistoryRepository? history, + Size size = const Size(390, 844), +}) async { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final catalog = FakeCatalogRepository( + banners: const [ + FeedBanner( + id: 'banner-1', + imageUrl: '/files/banner.jpg', + linkType: 'DRAMA', + linkValue: 'the-signal-demo', + ), + ], + ); + await tester.pumpWidget( + LumaDramaApp( + playerFactory: createWidgetPlayer, + catalogRepository: catalog, + categoryRepository: FakeCategoryRepository(catalog: catalog), + watchHistoryRepository: history ?? InMemoryWatchHistoryRepository(), + favoriteRepository: InMemoryFavoriteRepository(cards: catalog.dramas), + playbackRepository: FakePlaybackRepository(), + commentRepository: FakeCommentRepository(), + interactionRepository: FakeInteractionRepository(), + haptics: FakeHaptics(), + ), + ); + await tester.pumpAndSettle(); +} diff --git a/test/mine/mine_preview_layout_test.dart b/test/mine/mine_preview_layout_test.dart index 9edf04e..ddafc78 100644 --- a/test/mine/mine_preview_layout_test.dart +++ b/test/mine/mine_preview_layout_test.dart @@ -53,7 +53,7 @@ void main() { ), ); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('tab-my'))); + await tester.tap(find.byKey(const Key('tab-mine'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('mine-user-row')), findsOneWidget); diff --git a/test/mine/settings_page_test.dart b/test/mine/settings_page_test.dart index 0a8e1a8..def8456 100644 --- a/test/mine/settings_page_test.dart +++ b/test/mine/settings_page_test.dart @@ -44,7 +44,7 @@ void main() { ), ); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('tab-my'))); + await tester.tap(find.byKey(const Key('tab-mine'))); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('mine-settings'))); await tester.pumpAndSettle(); diff --git a/test/notice/notices_entry_test.dart b/test/notice/notices_entry_test.dart index 221d024..b413c55 100644 --- a/test/notice/notices_entry_test.dart +++ b/test/notice/notices_entry_test.dart @@ -44,7 +44,7 @@ void main() { ), ); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('tab-my'))); + await tester.tap(find.byKey(const Key('tab-mine'))); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('mine-settings'))); await tester.pumpAndSettle(); diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index b5ecb15..f18776f 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -29,7 +29,7 @@ void main() { tester, ) async { await _pumpApp(tester); - await tester.tap(find.byKey(const Key('tab-discover'))); + await tester.tap(find.byKey(const Key('tab-theater'))); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('home-drama-card-city-lights-demo'))); await tester.pumpAndSettle(); @@ -50,43 +50,9 @@ void main() { expect(tester.takeException(), isNull); }); - testWidgets('should_open_fullscreen_overlay_from_detail_cta_and_chip', ( - tester, - ) async { - await _pumpApp(tester); - await tester.binding.handlePopRoute(); - await tester.pumpAndSettle(); - await tester.binding.handlePopRoute(); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('drawer-open-series'))); - await tester.pumpAndSettle(); - - await tester.tap(find.byKey(const Key('detail-cta'))); - await tester.pumpAndSettle(); - _expectFullscreenOverlay(tester); - - await tester.binding.handlePopRoute(); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('detail-episode-chip-2'))); - await tester.pumpAndSettle(); - _expectFullscreenOverlay(tester); - expect( - tester.widget(find.byKey(const Key('player-title'))).data, - contains('Episode 2'), - ); - expect(tester.takeException(), isNull); - }); - testWidgets('should_show_center_toggle_only_when_paused', (tester) async { await _pumpApp(tester); - await tester.binding.handlePopRoute(); - await tester.pumpAndSettle(); - await tester.binding.handlePopRoute(); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('drawer-open-series'))); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('detail-cta'))); - await tester.pumpAndSettle(); + await _openSeriesPlayer(tester); expect(find.byKey(const Key('player-play-toggle')), findsNothing); @@ -118,14 +84,7 @@ void main() { tester, ) async { await _pumpApp(tester); - await tester.binding.handlePopRoute(); - await tester.pumpAndSettle(); - await tester.binding.handlePopRoute(); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('drawer-open-series'))); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('detail-cta'))); - await tester.pumpAndSettle(); + await _openSeriesPlayer(tester); expect(find.byKey(const Key('player-controls-top')), findsOneWidget); expect(find.byKey(const Key('player-controls-bottom')), findsOneWidget); @@ -208,14 +167,7 @@ void main() { testWidgets('should_fit_player_header_on_320_width', (tester) async { await _pumpApp(tester, size: const Size(320, 568)); - await tester.binding.handlePopRoute(); - await tester.pumpAndSettle(); - await tester.binding.handlePopRoute(); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('drawer-open-series'))); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('detail-cta'))); - await tester.pumpAndSettle(); + await _openSeriesPlayer(tester); expect( tester.getSize(find.byKey(const Key('player-back'))), @@ -228,14 +180,7 @@ void main() { tester, ) async { await _pumpApp(tester); - await tester.binding.handlePopRoute(); - await tester.pumpAndSettle(); - await tester.binding.handlePopRoute(); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('drawer-open-series'))); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('detail-cta'))); - await tester.pumpAndSettle(); + await _openSeriesPlayer(tester); await tester.tap(find.byKey(const Key('player-clear-screen'))); await tester.pump(); @@ -259,14 +204,7 @@ void main() { tester, ) async { await _pumpApp(tester); - await tester.binding.handlePopRoute(); - await tester.pumpAndSettle(); - await tester.binding.handlePopRoute(); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('drawer-open-series'))); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('detail-cta'))); - await tester.pumpAndSettle(); + await _openSeriesPlayer(tester); await tester.tap(find.byKey(const Key('player-more'))); await tester.pumpAndSettle(); @@ -298,14 +236,7 @@ void main() { tester, ) async { await _pumpApp(tester); - await tester.binding.handlePopRoute(); - await tester.pumpAndSettle(); - await tester.binding.handlePopRoute(); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('drawer-open-series'))); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('detail-cta'))); - await tester.pumpAndSettle(); + await _openSeriesPlayer(tester); await tester.tap(find.byKey(const Key('player-more'))); await tester.pumpAndSettle(); @@ -349,143 +280,13 @@ void main() { tester, ) async { await _pumpApp(tester); - await tester.binding.handlePopRoute(); - await tester.pumpAndSettle(); - await tester.binding.handlePopRoute(); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('drawer-open-series'))); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('detail-cta'))); - await tester.pumpAndSettle(); + await _openSeriesPlayer(tester); final bar = tester.widget( find.byKey(const Key('player-progress')), ); expect(bar.showTimes, isTrue); }); - - testWidgets('should_clear_and_restore_home_feed_chrome', (tester) async { - await _pumpApp(tester); - await tester.tap(find.byKey(const Key('tab-home'))); - await tester.pumpAndSettle(); - - expect(find.byKey(const Key('home-feed-title')), findsOneWidget); - expect(find.byKey(const Key('home-feed-info-card')), findsOneWidget); - expect(find.byKey(const Key('home-feed-scrim')), findsOneWidget); - expect(find.byKey(const Key('home-feed-center-play')), findsNothing); - - await tester.tap(find.byKey(const Key('home-feed-clear-screen'))); - await tester.pump(); - - expect(find.byKey(const Key('home-feed-title')), findsNothing); - expect(find.byKey(const Key('home-feed-info-card')), findsNothing); - expect(find.byKey(const Key('home-feed-scrim')), findsNothing); - expect(find.byKey(const Key('home-feed-video-tap')), findsOneWidget); - expect( - find.byKey(const Key('home-feed-exit-clear-screen')), - findsOneWidget, - ); - expect( - tester.getSize(find.byKey(const Key('home-feed-exit-clear-screen'))), - const Size(36, 36), - ); - expect(find.byKey(const Key('home-feed-center-play')), findsNothing); - expect( - find.ancestor( - of: find.byKey(const Key('home-feed-exit-clear-screen')), - matching: find.byType(SafeArea), - ), - findsWidgets, - ); - expect( - tester - .getTopLeft(find.byKey(const Key('home-feed-exit-clear-screen'))) - .dy, - greaterThanOrEqualTo(8), - ); - - await tester.tapAt(const Offset(80, 300)); - await tester.pump(); - expect(find.byKey(const Key('home-feed-title')), findsNothing); - expect( - find.byKey(const Key('home-feed-exit-clear-screen')), - findsOneWidget, - ); - expect(find.byKey(const Key('home-feed-center-play')), findsOneWidget); - expect( - tester - .widget( - find.byKey(const Key('home-feed-center-play')), - ) - .playing, - isFalse, - ); - - await tester.tapAt(const Offset(80, 300)); - await tester.pump(); - expect(find.byKey(const Key('home-feed-title')), findsNothing); - expect( - find.byKey(const Key('home-feed-exit-clear-screen')), - findsOneWidget, - ); - expect(find.byKey(const Key('home-feed-center-play')), findsNothing); - - await tester.tap(find.byKey(const Key('home-feed-exit-clear-screen'))); - await tester.pump(); - - expect(find.byKey(const Key('home-feed-title')), findsOneWidget); - expect(find.byKey(const Key('home-feed-info-card')), findsOneWidget); - expect(find.byKey(const Key('home-feed-scrim')), findsOneWidget); - expect(find.byKey(const Key('home-feed-exit-clear-screen')), findsNothing); - expect(find.byKey(const Key('home-feed-center-play')), findsNothing); - }); - - testWidgets('should_show_home_center_toggle_only_when_paused', ( - tester, - ) async { - await _pumpApp(tester); - await tester.tap(find.byKey(const Key('tab-home'))); - await tester.pumpAndSettle(); - - expect(find.byKey(const Key('home-feed-center-play')), findsNothing); - await tester.tapAt(const Offset(80, 300)); - await tester.pump(); - expect(find.byKey(const Key('home-feed-center-play')), findsOneWidget); - expect( - tester - .widget( - find.byKey(const Key('home-feed-center-play')), - ) - .playing, - isFalse, - ); - - await tester.tap(find.byKey(const Key('home-feed-center-play'))); - await tester.pump(); - expect(find.byKey(const Key('home-feed-center-play')), findsNothing); - }); - - testWidgets('should_keep_home_clear_state_when_swiping_to_next_page', ( - tester, - ) async { - await _pumpApp(tester); - await tester.tap(find.byKey(const Key('tab-home'))); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('home-feed-clear-screen'))); - await tester.pump(); - expect(find.byKey(const Key('home-feed-title')), findsNothing); - - await tester.drag(find.byType(PageView), const Offset(0, -550)); - await tester.pumpAndSettle(); - - expect(find.byKey(const Key('home-feed-title')), findsNothing); - expect(find.byKey(const Key('home-feed-info-card')), findsNothing); - expect( - find.byKey(const Key('home-feed-exit-clear-screen')), - findsOneWidget, - ); - expect(find.byKey(const Key('home-feed-center-play')), findsNothing); - }); } void _expectFullscreenOverlay(WidgetTester tester) { @@ -505,6 +306,13 @@ void _expectFullscreenOverlay(WidgetTester tester) { expect(find.byKey(const Key('rail-save')), findsNothing); } +Future _openSeriesPlayer(WidgetTester tester) async { + await tester.tap(find.byKey(const Key('tab-theater'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('home-drama-card-city-lights-demo'))); + await tester.pumpAndSettle(); +} + Future _pumpApp( WidgetTester tester, { Size size = const Size(390, 844), diff --git a/test/watch/watch_history_provider_test.dart b/test/watch/watch_history_provider_test.dart index 2b10336..aef7233 100644 --- a/test/watch/watch_history_provider_test.dart +++ b/test/watch/watch_history_provider_test.dart @@ -40,7 +40,7 @@ void main() { ), ); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('tab-my'))); + await tester.tap(find.byKey(const Key('tab-mine'))); await tester.pumpAndSettle(); await tester.drag(find.byType(ListView), const Offset(0, -520)); await tester.pumpAndSettle(); diff --git a/test/widget_test.dart b/test/widget_test.dart index 1fce792..09849bc 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -5,7 +5,12 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:fruitshorts/app.dart'; import 'package:fruitshorts/core/data/models/comment.dart'; -import 'package:fruitshorts/core/data/models/interaction.dart'; +import 'package:fruitshorts/core/data/repositories/comment_repository.dart'; +import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; +import 'package:fruitshorts/core/data/repositories/interaction_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/data/services/playback_session.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'; @@ -14,18 +19,8 @@ import 'support/fake_catalog.dart'; import 'support/fake_category.dart'; import 'support/fake_interaction_repositories.dart'; import 'support/widget_player.dart'; -import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; -import 'package:fruitshorts/core/data/repositories/catalog_repository.dart'; -import 'package:fruitshorts/core/data/repositories/comment_repository.dart'; -import 'package:fruitshorts/core/data/repositories/interaction_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/data/services/playback_session.dart'; void main() { - /// 默认按「已登录」起测:账号体系接上后,"登录"不再是本地演示开关, - /// 而是本地会话(这里直接给 shared_preferences 的内存实现塞一份会话)。 - /// 昵称/uid 与演示文案保持一致,避免为显示细节改写既有断言。 void seedSignedInSession() { SharedPreferencesAsyncPlatform .instance = InMemorySharedPreferencesAsync.withData({ @@ -35,32 +30,20 @@ void main() { }); } - void seedSignedOutSession() { - SharedPreferencesAsyncPlatform.instance = - InMemorySharedPreferencesAsync.empty(); - } + setUp(seedSignedInSession); - setUp(() { - seedSignedInSession(); - }); - - /// 所有 widget 测试都注入离线仓库:票据 / 进度 / 收藏都不碰网络, - /// 这样断言的是 UI 行为,而不是后端有没有起。 Widget appUnderTest({ PlayerFactory? playerFactory, - CatalogRepository? catalogRepository, CommentRepository? commentRepository, InteractionRepository? interactionRepository, }) { - final catalog = catalogRepository ?? FakeCatalogRepository(); + final catalog = FakeCatalogRepository(); return LumaDramaApp( playerFactory: playerFactory ?? createWidgetPlayer, catalogRepository: catalog, categoryRepository: FakeCategoryRepository(catalog: catalog), watchHistoryRepository: InMemoryWatchHistoryRepository(), - favoriteRepository: InMemoryFavoriteRepository( - cards: FakeCatalogRepository().dramas, - ), + favoriteRepository: InMemoryFavoriteRepository(cards: catalog.dramas), playbackRepository: FakePlaybackRepository(), commentRepository: commentRepository ?? FakeCommentRepository(), interactionRepository: @@ -68,36 +51,48 @@ void main() { ); } - testWidgets( - 'should_promote_next_without_recreating_it_or_showing_loading_when_home_is_swiped', - (tester) async { - final created = []; - await tester.pumpWidget( - appUnderTest( - playerFactory: (episode) { - created.add(episode.id); - return WidgetPlayer(); - }, - ), - ); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('tab-home'))); - await tester.pumpAndSettle(); - expect(created, hasLength(2)); - expect(find.byType(CircularProgressIndicator), findsNothing); - await tester.drag(find.byType(PageView), const Offset(0, -550)); - await tester.pump(); - expect(find.byType(CircularProgressIndicator), findsNothing); - await tester.pumpAndSettle(); - expect(find.text('City Lights & Velvet Nights'), findsOneWidget); - expect(created, hasLength(3)); - expect(created.toSet(), hasLength(3)); - await tester.pumpWidget(const SizedBox.shrink()); - await tester.pumpAndSettle(); - }, - ); + testWidgets('should_preload_next_episode_when_fullscreen_player_opens', ( + tester, + ) async { + final created = []; + await tester.pumpWidget( + appUnderTest( + playerFactory: (episode) { + created.add(episode.id); + return WidgetPlayer(); + }, + ), + ); + await tester.pumpAndSettle(); + await _openTheaterPlayer(tester); - testWidgets('should_open_series_detail_when_immersive_home_is_tapped', ( + expect(created, hasLength(2)); + expect(find.byType(CircularProgressIndicator), findsNothing); + await tester.drag(find.byType(PageView), const Offset(0, -550)); + await tester.pumpAndSettle(); + + expect( + tester.widget(find.byKey(const Key('player-title'))).data, + contains('Episode 2'), + ); + expect(created, hasLength(3)); + expect(created.toSet(), hasLength(3)); + }); + + testWidgets('should_open_fullscreen_player_when_home_card_is_tapped', ( + tester, + ) async { + await tester.pumpWidget(appUnderTest()); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('home-drama-card-the-signal-demo'))); + await tester.pumpAndSettle(); + + final player = tester.widget(find.byType(PlayerPage)); + expect(player.homeMode, isFalse); + expect(find.byKey(const Key('player-controls-top')), findsOneWidget); + }); + + testWidgets('should_keep_four_root_tabs_on_one_route_and_return_home', ( tester, ) async { tester.view.physicalSize = const Size(390, 844); @@ -107,232 +102,12 @@ void main() { await tester.pumpWidget(appUnderTest()); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('tab-home'))); - await tester.pumpAndSettle(); - expect(find.text('The Signal'), findsOneWidget); - expect(find.byType(Slider), findsNothing); - expect(find.text('Home'), findsWidgets); - expect(find.byKey(const Key('home-feed-title')), findsOneWidget); - expect(find.byKey(const Key('home-feed-info-card')), findsOneWidget); - expect(find.byKey(const Key('rail-like')), findsOneWidget); - expect(find.byKey(const Key('rail-comments')), findsOneWidget); - expect(find.byKey(const Key('rail-share')), findsOneWidget); - expect(find.byKey(const Key('rail-save')), findsOneWidget); - await tester.tap(find.byKey(const Key('home-feed-more'))); - await tester.pumpAndSettle(); - expect(find.byKey(const Key('home-feed-download')), findsOneWidget); - expect(find.byKey(const Key('home-feed-pip')), findsOneWidget); - await tester.binding.handlePopRoute(); - await tester.pumpAndSettle(); - expect(find.text('Watch Full'), findsNothing); - expect(find.byTooltip('Explore series'), findsNothing); - }); + await _openTheaterPlayer(tester); + expect(find.byType(PlayerPage), findsOneWidget); - testWidgets( - 'should_open_current_series_and_switch_root_tabs_when_quick_drawer_is_used', - (tester) async { - tester.view.physicalSize = const Size(360, 720); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - - await tester.pumpWidget(appUnderTest()); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('tab-discover'))); - await tester.pumpAndSettle(); - expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); - await tester.binding.handlePopRoute(); - await tester.pumpAndSettle(); - expect( - tester - .widget(find.byType(PlayerPage, skipOffstage: false)) - .active, - isFalse, - ); - expect(find.text('Quick access'), findsOneWidget); - expect(find.text('7 EPISODES'), findsOneWidget); - expect(tester.takeException(), isNull); - - await tester.tap(find.byKey(const Key('drawer-open-series'))); - await tester.pumpAndSettle(); - expect(find.byKey(const Key('detail-cta')), findsOneWidget); - await tester.binding.handlePopRoute(); - await tester.pumpAndSettle(); - expect( - tester - .widget(find.byType(PlayerPage, skipOffstage: false)) - .active, - isFalse, - ); - expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); - - await tester.binding.handlePopRoute(); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('drawer-discover'))); - await tester.pumpAndSettle(); - expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); - expect( - Navigator.of( - tester.element(find.byKey(const Key('home-all-dramas-grid'))), - ).canPop(), - isFalse, - ); - }, - ); - - testWidgets('should_follow_system_locale_without_language_switch', ( - tester, - ) async { - tester.view.physicalSize = const Size(390, 844); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - - await tester.pumpWidget(appUnderTest()); - await tester.pumpAndSettle(); - expect(find.byTooltip('Captions'), findsNothing); - await tester.tap(find.byKey(const Key('tab-my'))); - await tester.pumpAndSettle(); - expect(find.text('Vicky sheng'), findsOneWidget); - expect(find.text('Coins'), findsOneWidget); - expect(find.text('App Language'), findsNothing); - expect(find.text('简体中文'), findsNothing); - expect(find.byKey(const Key('mine-wallet')), findsOneWidget); - expect(find.text('Home'), findsWidgets); - await tester.tap(find.byKey(const Key('tab-discover'))); - await tester.pumpAndSettle(); - expect(find.text('Discover'), findsWidgets); - }); - - testWidgets( - 'should_fit_compact_android_viewport_when_home_controls_are_shown', - (tester) async { - tester.view.physicalSize = const Size(360, 720); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - - await tester.pumpWidget(appUnderTest()); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('tab-home'))); - await tester.pumpAndSettle(); - expect(find.text('The Signal'), findsOneWidget); - await tester.drag(find.byType(PageView), const Offset(0, -500)); - await tester.pumpAndSettle(); - expect(find.text('City Lights & Velvet Nights'), findsOneWidget); - await tester.tap(find.byKey(const Key('tab-discover'))); - await tester.pumpAndSettle(); - await tester.tap( - find.byKey(const Key('home-drama-card-city-lights-demo')), - ); - await tester.pumpAndSettle(); - expect(find.byType(Slider), findsOneWidget); - expect(tester.takeException(), isNull); - }, - ); - - testWidgets( - 'should_fit_compact_android_viewport_and_return_home_when_my_page_is_shown', - (tester) async { - tester.view.physicalSize = const Size(360, 720); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - - await tester.pumpWidget(appUnderTest()); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('tab-my'))); - await tester.pumpAndSettle(); - expect(tester.takeException(), isNull); - expect(find.text('Vicky sheng'), findsOneWidget); - expect(find.text('Coins'), findsOneWidget); - expect(tester.takeException(), isNull); - await tester.tap(find.byKey(const Key('tab-home'))); - await tester.pumpAndSettle(); - expect(find.text('The Signal'), findsOneWidget); - }, - ); - - testWidgets('should_fit_compact_dark_viewport_when_evening_theme_is_used', ( - tester, - ) async { - tester.view.physicalSize = const Size(320, 568); - tester.view.devicePixelRatio = 1; - tester.platformDispatcher.platformBrightnessTestValue = Brightness.dark; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - addTearDown(tester.platformDispatcher.clearPlatformBrightnessTestValue); - - await tester.pumpWidget(appUnderTest()); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('tab-home'))); - await tester.pumpAndSettle(); - expect(find.text('The Signal'), findsOneWidget); - expect(tester.takeException(), isNull); - - await tester.tap(find.byKey(const Key('tab-my'))); - await tester.pumpAndSettle(); - expect(find.text('Coins'), findsOneWidget); - expect(tester.takeException(), isNull); - }); - - testWidgets( - 'should_show_three_playable_dramas_and_one_pending_poster_when_discover_is_opened', - (tester) async { - tester.view.physicalSize = const Size(360, 720); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - - await tester.pumpWidget(appUnderTest()); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('tab-discover'))); - await tester.pumpAndSettle(); - - expect(find.byKey(const Key('discover-category-grid')), findsOneWidget); - expect(find.byKey(const Key('discover-ranking-section')), findsOneWidget); - expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); - expect(find.byKey(const Key('discover-profile')), findsOneWidget); - expect( - find.byKey( - const Key('home-drama-genre-city-lights-demo'), - skipOffstage: false, - ), - findsOneWidget, - ); - expect( - find.byKey( - const Key('home-drama-genre-vengeance-demo'), - skipOffstage: false, - ), - findsOneWidget, - ); - expect(find.byTooltip('Search'), findsOneWidget); - expect(tester.takeException(), isNull); - - await tester.tap(find.byKey(const Key('tab-my'))); - await tester.pumpAndSettle(); - expect(find.byKey(const Key('mine-menu')), findsOneWidget); - await tester.tap(find.byKey(const Key('tab-home'))); - await tester.pumpAndSettle(); - expect(find.text('The Signal'), findsOneWidget); - }, - ); - - testWidgets('should_stay_on_one_route_when_bottom_tabs_are_used', ( - tester, - ) async { - tester.view.physicalSize = const Size(390, 844); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - - await tester.pumpWidget(appUnderTest()); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('tab-discover'))); + await tester.tap(find.byKey(const Key('player-back'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); - expect(find.byTooltip('Search'), findsOneWidget); expect( Navigator.of( tester.element(find.byKey(const Key('home-all-dramas-grid'))), @@ -340,278 +115,187 @@ void main() { isFalse, ); - await tester.tap(find.byKey(const Key('tab-my'))); + await tester.binding.handlePopRoute(); await tester.pumpAndSettle(); - expect(find.byKey(const Key('mine-history')), findsOneWidget); + expect(find.byKey(const Key('home-browse-page')), findsOneWidget); + + await tester.tap(find.byKey(const Key('tab-create'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('create-page')), findsOneWidget); + await tester.tap(find.byKey(const Key('tab-mine'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('mine-menu')), findsOneWidget); expect( Navigator.of(tester.element(find.byKey(const Key('mine-menu')))).canPop(), isFalse, ); - - await tester.tap(find.byKey(const Key('tab-discover'))); - await tester.pumpAndSettle(); - expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); - expect(find.byTooltip('Search'), findsOneWidget); - expect( - Navigator.of( - tester.element(find.byKey(const Key('home-all-dramas-grid'))), - ).canPop(), - isFalse, - ); - await tester.tap(find.byKey(const Key('tab-my'))); - await tester.pumpAndSettle(); - expect(find.byKey(const Key('mine-menu')), findsOneWidget); - await tester.tap(find.byKey(const Key('tab-home'))); - await tester.pumpAndSettle(); - expect(find.text('The Signal'), findsOneWidget); - expect( - Navigator.of(tester.element(find.text('The Signal'))).canPop(), - isFalse, - ); }); - testWidgets( - 'should_change_drama_on_home_swipe_and_episode_on_player_swipe_when_swiping', - (tester) async { - tester.view.physicalSize = const Size(390, 844); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - - await tester.pumpWidget(appUnderTest()); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('tab-home'))); - await tester.pumpAndSettle(); - expect(find.text('The Signal'), findsOneWidget); - - await tester.drag(find.byType(PageView), const Offset(0, -550)); - await tester.pumpAndSettle(); - expect(find.text('City Lights & Velvet Nights'), findsOneWidget); - expect( - tester - .widget(find.byKey(const Key('home-feed-episode-title'))) - .data, - contains('Episode 1'), - ); - - await tester.tap(find.byKey(const Key('tab-discover'))); - await tester.pumpAndSettle(); - await tester.tap( - find.byKey(const Key('home-drama-card-city-lights-demo')), - ); - await tester.pumpAndSettle(); - expect(find.byType(Slider), findsOneWidget); - await tester.drag(find.byType(PageView), const Offset(0, -550)); - await tester.pumpAndSettle(); - expect( - tester.widget(find.byKey(const Key('player-title'))).data, - contains('Episode 2'), - ); - expect(find.textContaining('City Lights & Velvet Nights'), findsWidgets); - }, - ); - - testWidgets('should_hide_category_navigation_when_shorts_page_is_shown', ( + testWidgets('should_follow_system_locale_without_language_switch', ( tester, ) async { - tester.view.physicalSize = const Size(390, 844); + await tester.pumpWidget(appUnderTest()); + await tester.pumpAndSettle(); + expect(find.byTooltip('Captions'), findsNothing); + + await tester.tap(find.byKey(const Key('tab-mine'))); + await tester.pumpAndSettle(); + expect(find.text('Vicky sheng'), findsOneWidget); + expect(find.text('Coins'), findsOneWidget); + expect(find.text('App Language'), findsNothing); + expect(find.byKey(const Key('mine-wallet')), findsOneWidget); + + await tester.tap(find.byKey(const Key('tab-theater'))); + await tester.pumpAndSettle(); + expect(find.text('Theater'), findsWidgets); + }); + + testWidgets('should_fit_compact_viewport_when_player_is_opened', ( + tester, + ) async { + tester.view.physicalSize = const Size(360, 720); tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); await tester.pumpWidget(appUnderTest()); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('tab-home'))); + await _openTheaterPlayer(tester); + + expect(find.byType(Slider), findsOneWidget); + await tester.drag(find.byType(PageView), const Offset(0, -500)); await tester.pumpAndSettle(); - expect(find.text('Following'), findsNothing); - expect(find.text('Animated'), findsNothing); - expect(find.text('Live Action'), findsNothing); - expect(find.text('Recommended'), findsNothing); - expect(find.text('The Signal'), findsOneWidget); - await tester.drag(find.byType(PageView), const Offset(0, -550)); - await tester.pumpAndSettle(); - expect(find.text('City Lights & Velvet Nights'), findsOneWidget); + expect( + tester.widget(find.byKey(const Key('player-title'))).data, + contains('Episode 2'), + ); expect(tester.takeException(), isNull); }); - testWidgets( - 'should_save_comment_like_and_share_in_order_when_right_rail_is_used', - (tester) async { - tester.view.physicalSize = const Size(390, 844); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - String? sharedText; - final interactions = FakeInteractionRepository(); - final comments = FakeCommentRepository(pages: [const CommentPage()]); - const channel = MethodChannel('com.stormg.lumadrama/share'); - tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, ( - call, - ) async { - sharedText = (call.arguments as Map)['text'] as String; - return null; - }); - addTearDown( - () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - channel, - null, - ), - ); - await tester.pumpWidget( - appUnderTest( - commentRepository: comments, - interactionRepository: interactions, - ), - ); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('tab-home'))); - await tester.pumpAndSettle(); - final keys = [ - const Key('rail-like'), - const Key('rail-comments'), - const Key('rail-share'), - const Key('rail-save'), - ]; - final tops = keys - .map((key) => tester.getTopLeft(find.byKey(key)).dy) - .toList(); - expect(tops, orderedEquals(tops.toList()..sort())); + testWidgets('should_render_theater_with_filters_and_same_route_tabs', ( + tester, + ) async { + tester.view.physicalSize = const Size(360, 720); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); - await tester.tap(find.byKey(const Key('rail-save'))); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('rail-comments'))); - await tester.pumpAndSettle(); - expect(find.text('No comments yet.'), findsOneWidget); - await tester.enterText( - find.byKey(const Key('comment-input')), - 'Nice demo', - ); - await tester.tap(find.byKey(const Key('comment-post'))); - await tester.pumpAndSettle(); - expect(find.text('Nice demo'), findsOneWidget); - await tester.binding.handlePopRoute(); - await tester.pumpAndSettle(); + await tester.pumpWidget(appUnderTest()); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('tab-theater'))); + await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('rail-like'))); - await tester.pumpAndSettle(); - expect(interactions.likeTargets, contains('DRAMA:the-signal-demo')); - await tester.tap(find.byKey(const Key('rail-share'))); - await tester.pumpAndSettle(); - expect(sharedText, contains('The Signal')); - expect(sharedText, contains('A demo series for vertical playback')); - expect(sharedText, contains('FruitShorts')); - expect(tester.takeException(), isNull); - }, - ); + expect(find.byKey(const Key('discover-search')), findsOneWidget); + expect(find.byKey(const Key('theater-category-chips')), findsOneWidget); + expect(find.byKey(const Key('theater-language-chips')), findsOneWidget); + expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); + expect(find.byKey(const Key('discover-profile')), findsOneWidget); + expect( + find.byKey(const Key('home-drama-card-city-lights-demo')), + findsOneWidget, + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('should_save_comment_like_and_share_from_player_more', ( + tester, + ) async { + String? sharedText; + final interactions = FakeInteractionRepository(); + final comments = FakeCommentRepository(pages: [const CommentPage()]); + const channel = MethodChannel('com.stormg.lumadrama/share'); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, ( + call, + ) async { + sharedText = (call.arguments as Map)['text'] as String; + return null; + }); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + null, + ), + ); + + await tester.pumpWidget( + appUnderTest( + commentRepository: comments, + interactionRepository: interactions, + ), + ); + await tester.pumpAndSettle(); + await _openTheaterPlayer(tester); + + await tester.tap(find.byKey(const Key('player-comments'))); + await tester.pumpAndSettle(); + expect(find.text('No comments yet.'), findsOneWidget); + await tester.enterText(find.byKey(const Key('comment-input')), 'Nice demo'); + await tester.tap(find.byKey(const Key('comment-post'))); + await tester.pumpAndSettle(); + expect(find.text('Nice demo'), findsOneWidget); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + + await _tapPlayerMore(tester, const Key('player-more-like')); + expect(interactions.likeTargets, contains('DRAMA:city-lights-demo')); + + await _tapPlayerMore(tester, const Key('player-more-favorite')); + await _tapPlayerMore(tester, const Key('player-more-share')); + expect(sharedText, contains('City Lights & Velvet Nights')); + expect(sharedText, contains('FruitShorts')); + expect(tester.takeException(), isNull); + }); testWidgets('should_open_auth_gate_without_liking_when_signed_out', ( tester, ) async { - tester.view.physicalSize = const Size(390, 844); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - seedSignedOutSession(); + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); final interactions = FakeInteractionRepository(); await tester.pumpWidget(appUnderTest(interactionRepository: interactions)); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('tab-home'))); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('rail-like'))); - await tester.pumpAndSettle(); + await _openTheaterPlayer(tester); + await _tapPlayerMore(tester, const Key('player-more-like')); expect(find.text('Sign in to continue'), findsOneWidget); expect(interactions.likeTargets, isEmpty); }); - testWidgets('should_report_drama_from_detail_more_menu_when_submitted', ( - tester, - ) async { + testWidgets('should_show_saved_player_drama_in_favorites', (tester) async { tester.view.physicalSize = const Size(390, 844); tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); - final interactions = FakeInteractionRepository(); - await tester.pumpWidget(appUnderTest(interactionRepository: interactions)); + await tester.pumpWidget(appUnderTest()); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('tab-discover'))); + await _openTheaterPlayer(tester); + await _tapPlayerMore(tester, const Key('player-more-favorite')); + + await tester.tap(find.byKey(const Key('player-back'))); await tester.pumpAndSettle(); - await tester.binding.handlePopRoute(); + await tester.tap(find.byKey(const Key('tab-mine'))); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('drawer-open-series'))); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('detail-more'))); - await tester.pumpAndSettle(); - await tester.tap(find.text('Report drama')); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('report-submit'))); + await tester.tap(find.byKey(const Key('mine-watchlist'))); await tester.pumpAndSettle(); - expect(interactions.reports.single.targetType, InteractionTargetType.drama); - expect(find.text('Report submitted. Thank you.'), findsOneWidget); + expect(find.text('City Lights & Velvet Nights'), findsWidgets); }); +} - testWidgets( - 'should_open_drawer_and_stay_on_home_when_system_back_is_pressed', - (tester) async { - tester.view.physicalSize = const Size(390, 844); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - await tester.pumpWidget(appUnderTest()); - await tester.pumpAndSettle(); +Future _openTheaterPlayer(WidgetTester tester) async { + await tester.tap(find.byKey(const Key('tab-theater'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('home-drama-card-city-lights-demo'))); + await tester.pumpAndSettle(); +} - await tester.tap(find.byKey(const Key('tab-discover'))); - await tester.pumpAndSettle(); - expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); - await tester.binding.handlePopRoute(); - await tester.pumpAndSettle(); - expect(find.text('Quick access'), findsOneWidget); - expect(find.byKey(const Key('home-tabs-bar')), findsOneWidget); - await tester.binding.handlePopRoute(); - await tester.pumpAndSettle(); - expect(find.text('Quick access'), findsNothing); - expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); - expect( - Navigator.of( - tester.element(find.byKey(const Key('home-tabs-bar'))), - ).canPop(), - isFalse, - ); - }, - ); - - testWidgets( - 'should_list_saved_dramas_across_home_feed_when_watchlist_is_opened', - (tester) async { - tester.view.physicalSize = const Size(390, 844); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - // 收藏要求登录态:沿用本文件已有的"假登录"初始化 - seedSignedInSession(); - await tester.pumpWidget(appUnderTest()); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('tab-home'))); - await tester.pumpAndSettle(); - - await tester.tap(find.byKey(const Key('rail-save'))); - await tester.pumpAndSettle(); - await tester.drag(find.byType(PageView), const Offset(0, -550)); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('rail-save'))); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('tab-my'))); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('mine-watchlist'))); - await tester.pumpAndSettle(); - expect(find.text('The Signal'), findsOneWidget); - expect(find.text('City Lights & Velvet Nights'), findsWidgets); - await tester.tap(find.text('The Signal')); - await tester.pumpAndSettle(); - expect(find.byKey(const Key('detail-cta')), findsOneWidget); - }, - ); +Future _tapPlayerMore(WidgetTester tester, Key action) async { + await tester.tap(find.byKey(const Key('player-more'))); + await tester.pumpAndSettle(); + await tester.ensureVisible(find.byKey(action)); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(action)); + await tester.pumpAndSettle(); }