From 87db5485910f4e7728b817f00bed3d91de257c7c Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 19 Oct 2026 15:50:07 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(theme):=20=E6=A0=A1=E6=AD=A3=20K26=20?= =?UTF-8?q?=E9=A2=84=E8=A7=88=E8=89=B2=E6=9D=BF=E4=B8=8E=E5=AF=BC=E8=88=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/theme/app_colors.dart | 23 +++++++---- lib/core/theme/app_dimens.dart | 32 +++++++++----- lib/core/widgets/app_bottom_nav.dart | 20 ++------- lib/core/widgets/glass_panel.dart | 2 +- lib/l10n/app_en.arb | 23 ++++++++++- lib/l10n/app_id.arb | 23 ++++++++++- lib/l10n/app_localizations.dart | 62 +++++++++++++++++++++++++++- lib/l10n/app_localizations_en.dart | 38 ++++++++++++++++- lib/l10n/app_localizations_id.dart | 38 ++++++++++++++++- lib/l10n/app_localizations_zh.dart | 38 ++++++++++++++++- lib/l10n/app_zh.arb | 25 ++++++++++- lib/pages/home/home_view.dart | 11 ++--- test/theme/app_dimens_test.dart | 10 ++--- test/theme/app_theme_test.dart | 6 ++- 14 files changed, 293 insertions(+), 58 deletions(-) diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart index beca1ee..da46392 100644 --- a/lib/core/theme/app_colors.dart +++ b/lib/core/theme/app_colors.dart @@ -38,25 +38,34 @@ abstract final class AppColors { static const ink = Color(0xFF0B0C10); /// Bottom navigation background (rendered at 95% with blur). - static const bar = Color(0xFF0B0C10); + static const bar = Color(0xFF0D0D11); /// Cards lifted off [ink]. - static const card = Color(0xFF16181F); + static const card = Color(0xFF1B1C21); + + /// Alternate card surface from the mobile preview. + static const cardAlt = Color(0xFF1C1D21); /// Floating surfaces, inputs, and nested rows. - static const raised = Color(0xFF1D1E28); + static const raised = Color(0xFF1C1D21); + static const quick = Color(0xFF202126); + static const input = Color(0xFF25252A); + static const goldCard = Color(0xFF201B1E); /// Body text on dark surfaces. - static const cream = Color(0xFFE3E2E8); + static const cream = Color(0xFFE6E1E3); /// Titles and high-emphasis text. static const onSurface = Color(0xFFFFFFFF); /// Secondary text. - static const muted = Color(0xFFA1A1AA); + static const muted = Color(0xFFD2CDD0); /// Weak hints and timestamps. - static const subtle = Color(0xFF71717A); + static const subtle = Color(0xFF9A96A3); + + /// Soft orange used for continue labels and secondary times. + static const accent = Color(0xFFFFA490); /// 8% white border used by cards and inputs. static const border = Color(0x14FFFFFF); @@ -65,7 +74,7 @@ abstract final class AppColors { static const glassBorder = Color(0x24FFFFFF); static const outlineDark = Color(0xFF2A2C36); - static const outlineVariantDark = Color(0xFF242630); + static const outlineVariantDark = Color(0xFF242329); static const outlineLight = Color(0xFF8E8E93); static const outlineVariantLight = Color(0xFFE4E4E7); } diff --git a/lib/core/theme/app_dimens.dart b/lib/core/theme/app_dimens.dart index ee86e8a..9bc0cb6 100644 --- a/lib/core/theme/app_dimens.dart +++ b/lib/core/theme/app_dimens.dart @@ -9,15 +9,17 @@ abstract final class AppDimens { static const double spaceXl = 24; static const double pageHorizontal = 16; - static const double cardRadius = 16; + static const double cardRadius = 12; static const double authCardRadius = 20; static const double cardPadding = 16; - static const double innerRadius = 12; + static const double innerRadius = 10; static const double innerPadding = 12; static const double pillRadius = 20; - static const double glassSmallRadius = 16; - static const double glassRadius = 24; - static const double glassCardRadius = 26; + static const double glassSmallRadius = 12; + static const double glassRadius = 14; + static const double glassCardRadius = 14; + static const double buttonRadius = 9; + static const double coverRadius = 10; static const double caption = 12; static const double emptyStateSubtitle = 12.5; @@ -27,17 +29,25 @@ abstract final class AppDimens { static const double sectionTitle = 16; static const double pageTitle = 20; static const double detailHeadline = 24; - static const double navLabel = 11; + static const double detailHeadlineSmall = 18; + static const double previewCardTitle = 13; + static const double previewSectionTitle = 14; + static const double chipFont = 10; + static const double navLabel = 9; static const double buttonHeight = 48; static const double primaryButtonHeight = 52; + static const double buttonCompact = 32; + static const double buttonSmall = 34; + static const double buttonMedium = 40; + static const double buttonLarge = 44; static const double tapTarget = 44; - static const double navHeight = 64; - static const double navIcon = 22; - static const double progressHeight = 4; - static const double progressRadius = 4; - static const double progressLineHeight = 1.5; + static const double navHeight = 54; + static const double navIcon = 17; + static const double progressHeight = 3; + static const double progressRadius = 2; + static const double progressLineHeight = 3; static const double searchHeight = 44; static const double navDot = 3; diff --git a/lib/core/widgets/app_bottom_nav.dart b/lib/core/widgets/app_bottom_nav.dart index ec7ce59..898d032 100644 --- a/lib/core/widgets/app_bottom_nav.dart +++ b/lib/core/widgets/app_bottom_nav.dart @@ -16,7 +16,6 @@ class AppBottomNavItem { required this.label, required this.onTap, this.selected = false, - this.indicatorKey, }); final Key tabKey; @@ -25,7 +24,6 @@ class AppBottomNavItem { final String label; final VoidCallback onTap; final bool selected; - final Key? indicatorKey; } /// Shared 3-tab navigation used by all root tabs and the player fallback. @@ -52,7 +50,9 @@ class AppBottomNav extends StatelessWidget { height: AppDimens.navHeight, decoration: BoxDecoration( color: backgroundColor.withValues(alpha: 0.95), - border: const Border(top: BorderSide(color: AppColors.border)), + border: const Border( + top: BorderSide(color: AppColors.outlineVariantDark), + ), ), child: Row( children: [for (final item in items) _NavDestination(item: item)], @@ -94,7 +94,7 @@ class _NavDestination extends StatelessWidget { : Colors.white.withValues(alpha: 0.45), ), ), - const SizedBox(height: 3), + const SizedBox(height: 2), Text( item.label, maxLines: 1, @@ -108,18 +108,6 @@ class _NavDestination extends StatelessWidget { letterSpacing: 0, ), ), - const SizedBox(height: 3), - AnimatedContainer( - key: item.indicatorKey, - duration: const Duration(milliseconds: 160), - curve: Curves.easeOut, - width: AppDimens.navDot, - height: AppDimens.navDot, - decoration: BoxDecoration( - color: item.selected ? AppColors.blue : Colors.transparent, - shape: BoxShape.circle, - ), - ), ], ), ), diff --git a/lib/core/widgets/glass_panel.dart b/lib/core/widgets/glass_panel.dart index 6e340d4..02d144c 100644 --- a/lib/core/widgets/glass_panel.dart +++ b/lib/core/widgets/glass_panel.dart @@ -13,7 +13,7 @@ class GlassPanel extends StatelessWidget { super.key, required this.child, this.radius = AppDimens.glassRadius, - this.blur = AppDimens.glassBlur, + this.blur = 10, this.padding, this.borderColor = AppColors.glassBorder, this.gradient, diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index ccf4bcf..671ee4f 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -15,6 +15,14 @@ "discoverTab": "Discover", "searchDiscover": "Search dramas, actors, genres...", "featured": "Featured", + "recommended": "Recommended", + "fruitShortsBrand": "FruitShorts", + "authLandingSubtitle": "Sign in to sync progress, favorites and coins", + "comingSoon": "Coming soon", + "authLegal": "By continuing, you agree to the Terms of Service and Privacy Policy.", + "authContinueGoogle": "Continue with Google", + "authPhoneSignIn": "Phone sign in", + "authEmailSignIn": "Email sign in", "suspense": "Suspense", "romance": "Romance", "actionGenre": "Action", @@ -31,6 +39,19 @@ "startWatching": "Start Watching", "showLess": "Less", "views": "views", + "episodeOfTotal": "Episode {current} / {total}", + "@episodeOfTotal": { + "placeholders": { + "current": {"type": "int"}, + "total": {"type": "int"} + } + }, + "minutesAgo": "{count} min ago", + "@minutesAgo": { + "placeholders": { + "count": {"type": "int"} + } + }, "artworkOnly": "Artwork", "artworkOnlyNotice": "This is a visual catalog item. No video is available yet.", "videoPending": "Video pending", @@ -52,7 +73,7 @@ }, "resume": "Resume", "history": "History", - "watchlist": "Watchlist", + "watchlist": "Favorites", "likedTab": "Liked", "all": "All", "inProgress": "In Progress", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 22a79f2..c75f7c3 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -12,9 +12,17 @@ "openQuickMenu": "Buka menu cepat", "quickAccess": "Akses cepat", "currentSeries": "Serial saat ini", - "discoverTab": "Jelajahi", + "discoverTab": "Temukan", "searchDiscover": "Cari drama, aktor, genre...", "featured": "Unggulan", + "recommended": "Rekomendasi", + "fruitShortsBrand": "FruitShorts", + "authLandingSubtitle": "Masuk untuk menyinkronkan progres, favorit, dan koin", + "comingSoon": "Segera hadir", + "authLegal": "Dengan melanjutkan, Anda menyetujui Ketentuan Layanan dan Kebijakan Privasi.", + "authContinueGoogle": "Lanjutkan dengan Google", + "authPhoneSignIn": "Masuk dengan ponsel", + "authEmailSignIn": "Masuk dengan email", "suspense": "Thriller", "romance": "Romantis", "actionGenre": "Aksi", @@ -31,6 +39,19 @@ "startWatching": "Mulai Menonton", "showLess": "Lebih sedikit", "views": "tayangan", + "episodeOfTotal": "Episode {current} / {total}", + "@episodeOfTotal": { + "placeholders": { + "current": {"type": "int"}, + "total": {"type": "int"} + } + }, + "minutesAgo": "{count} mnt lalu", + "@minutesAgo": { + "placeholders": { + "count": {"type": "int"} + } + }, "artworkOnly": "Gambar", "artworkOnlyNotice": "Ini hanya gambar katalog. Videonya belum tersedia.", "videoPending": "Video belum siap", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index ee0214e..cf14047 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -190,6 +190,54 @@ abstract class AppLocalizations { /// **'Featured'** String get featured; + /// No description provided for @recommended. + /// + /// In en, this message translates to: + /// **'Recommended'** + String get recommended; + + /// No description provided for @fruitShortsBrand. + /// + /// In en, this message translates to: + /// **'FruitShorts'** + String get fruitShortsBrand; + + /// No description provided for @authLandingSubtitle. + /// + /// In en, this message translates to: + /// **'Sign in to sync progress, favorites and coins'** + String get authLandingSubtitle; + + /// No description provided for @comingSoon. + /// + /// In en, this message translates to: + /// **'Coming soon'** + String get comingSoon; + + /// No description provided for @authLegal. + /// + /// In en, this message translates to: + /// **'By continuing, you agree to the Terms of Service and Privacy Policy.'** + String get authLegal; + + /// No description provided for @authContinueGoogle. + /// + /// In en, this message translates to: + /// **'Continue with Google'** + String get authContinueGoogle; + + /// No description provided for @authPhoneSignIn. + /// + /// In en, this message translates to: + /// **'Phone sign in'** + String get authPhoneSignIn; + + /// No description provided for @authEmailSignIn. + /// + /// In en, this message translates to: + /// **'Email sign in'** + String get authEmailSignIn; + /// No description provided for @suspense. /// /// In en, this message translates to: @@ -286,6 +334,18 @@ abstract class AppLocalizations { /// **'views'** String get views; + /// No description provided for @episodeOfTotal. + /// + /// In en, this message translates to: + /// **'Episode {current} / {total}'** + String episodeOfTotal(int current, int total); + + /// No description provided for @minutesAgo. + /// + /// In en, this message translates to: + /// **'{count} min ago'** + String minutesAgo(int count); + /// No description provided for @artworkOnly. /// /// In en, this message translates to: @@ -373,7 +433,7 @@ abstract class AppLocalizations { /// No description provided for @watchlist. /// /// In en, this message translates to: - /// **'Watchlist'** + /// **'Favorites'** String get watchlist; /// No description provided for @likedTab. diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index d39d132..3a00090 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -54,6 +54,32 @@ class AppLocalizationsEn extends AppLocalizations { @override String get featured => 'Featured'; + @override + String get recommended => 'Recommended'; + + @override + String get fruitShortsBrand => 'FruitShorts'; + + @override + String get authLandingSubtitle => + 'Sign in to sync progress, favorites and coins'; + + @override + String get comingSoon => 'Coming soon'; + + @override + String get authLegal => + 'By continuing, you agree to the Terms of Service and Privacy Policy.'; + + @override + String get authContinueGoogle => 'Continue with Google'; + + @override + String get authPhoneSignIn => 'Phone sign in'; + + @override + String get authEmailSignIn => 'Email sign in'; + @override String get suspense => 'Suspense'; @@ -102,6 +128,16 @@ class AppLocalizationsEn extends AppLocalizations { @override String get views => 'views'; + @override + String episodeOfTotal(int current, int total) { + return 'Episode $current / $total'; + } + + @override + String minutesAgo(int count) { + return '$count min ago'; + } + @override String get artworkOnly => 'Artwork'; @@ -149,7 +185,7 @@ class AppLocalizationsEn extends AppLocalizations { String get history => 'History'; @override - String get watchlist => 'Watchlist'; + String get watchlist => 'Favorites'; @override String get likedTab => 'Liked'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 06dd59f..5f2b421 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -46,7 +46,7 @@ class AppLocalizationsId extends AppLocalizations { String get currentSeries => 'Serial saat ini'; @override - String get discoverTab => 'Jelajahi'; + String get discoverTab => 'Temukan'; @override String get searchDiscover => 'Cari drama, aktor, genre...'; @@ -54,6 +54,32 @@ class AppLocalizationsId extends AppLocalizations { @override String get featured => 'Unggulan'; + @override + String get recommended => 'Rekomendasi'; + + @override + String get fruitShortsBrand => 'FruitShorts'; + + @override + String get authLandingSubtitle => + 'Masuk untuk menyinkronkan progres, favorit, dan koin'; + + @override + String get comingSoon => 'Segera hadir'; + + @override + String get authLegal => + 'Dengan melanjutkan, Anda menyetujui Ketentuan Layanan dan Kebijakan Privasi.'; + + @override + String get authContinueGoogle => 'Lanjutkan dengan Google'; + + @override + String get authPhoneSignIn => 'Masuk dengan ponsel'; + + @override + String get authEmailSignIn => 'Masuk dengan email'; + @override String get suspense => 'Thriller'; @@ -102,6 +128,16 @@ class AppLocalizationsId extends AppLocalizations { @override String get views => 'tayangan'; + @override + String episodeOfTotal(int current, int total) { + return 'Episode $current / $total'; + } + + @override + String minutesAgo(int count) { + return '$count mnt lalu'; + } + @override String get artworkOnly => 'Gambar'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 8453aca..b3fb2b3 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -45,7 +45,7 @@ class AppLocalizationsZh extends AppLocalizations { String get currentSeries => '当前短剧'; @override - String get discoverTab => '找剧'; + String get discoverTab => '发现'; @override String get searchDiscover => '搜索短剧、演员、类型…'; @@ -53,6 +53,30 @@ class AppLocalizationsZh extends AppLocalizations { @override String get featured => '精选'; + @override + String get recommended => '推荐'; + + @override + String get fruitShortsBrand => '水果短剧'; + + @override + String get authLandingSubtitle => '登录后同步进度、收藏与金币权益'; + + @override + String get comingSoon => '即将开放'; + + @override + String get authLegal => '继续即代表同意《用户协议》与《隐私政策》'; + + @override + String get authContinueGoogle => '使用 Google 继续'; + + @override + String get authPhoneSignIn => '手机号登录'; + + @override + String get authEmailSignIn => '邮箱登录'; + @override String get suspense => '悬疑'; @@ -101,6 +125,16 @@ class AppLocalizationsZh extends AppLocalizations { @override String get views => '次播放'; + @override + String episodeOfTotal(int current, int total) { + return '第 $current 集 / 共 $total 集'; + } + + @override + String minutesAgo(int count) { + return '$count 分钟前'; + } + @override String get artworkOnly => '海报展示'; @@ -146,7 +180,7 @@ class AppLocalizationsZh extends AppLocalizations { String get history => '历史'; @override - String get watchlist => '追剧'; + String get watchlist => '收藏'; @override String get likedTab => '喜欢'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 374f17a..0ddd3bb 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -12,9 +12,17 @@ "openQuickMenu": "打开快捷菜单", "quickAccess": "快捷入口", "currentSeries": "当前短剧", - "discoverTab": "找剧", + "discoverTab": "发现", "searchDiscover": "搜索短剧、演员、类型…", "featured": "精选", + "recommended": "推荐", + "fruitShortsBrand": "水果短剧", + "authLandingSubtitle": "登录后同步进度、收藏与金币权益", + "comingSoon": "即将开放", + "authLegal": "继续即代表同意《用户协议》与《隐私政策》", + "authContinueGoogle": "使用 Google 继续", + "authPhoneSignIn": "手机号登录", + "authEmailSignIn": "邮箱登录", "suspense": "悬疑", "romance": "都市爱情", "actionGenre": "动作", @@ -31,6 +39,19 @@ "startWatching": "开始观看", "showLess": "收起", "views": "次播放", + "episodeOfTotal": "第 {current} 集 / 共 {total} 集", + "@episodeOfTotal": { + "placeholders": { + "current": {"type": "int"}, + "total": {"type": "int"} + } + }, + "minutesAgo": "{count} 分钟前", + "@minutesAgo": { + "placeholders": { + "count": {"type": "int"} + } + }, "artworkOnly": "海报展示", "artworkOnlyNotice": "这张海报仅用于目录展示,暂时没有可播放视频。", "videoPending": "视频待提供", @@ -52,7 +73,7 @@ }, "resume": "继续播放", "history": "历史", - "watchlist": "追剧", + "watchlist": "收藏", "likedTab": "喜欢", "all": "全部", "inProgress": "未看完", diff --git a/lib/pages/home/home_view.dart b/lib/pages/home/home_view.dart index 069046b..2bdb917 100644 --- a/lib/pages/home/home_view.dart +++ b/lib/pages/home/home_view.dart @@ -245,11 +245,10 @@ class _HomeTabsState extends State<_HomeTabs> { items: [ AppBottomNavItem( tabKey: const Key('tab-home'), - icon: Icons.home_outlined, - selectedIcon: Icons.home_rounded, + icon: Icons.play_circle_outline_rounded, + selectedIcon: Icons.play_circle_fill_rounded, label: l10n.homeTab, selected: logic.tab == 0, - indicatorKey: const Key('tab-home-indicator'), onTap: () => _selectTab(context, 0), ), AppBottomNavItem( @@ -258,16 +257,14 @@ class _HomeTabsState extends State<_HomeTabs> { selectedIcon: Icons.explore_rounded, label: l10n.discoverTab, selected: logic.tab == 1, - indicatorKey: const Key('tab-discover-indicator'), onTap: () => _selectTab(context, 1), ), AppBottomNavItem( tabKey: const Key('tab-my'), - icon: Icons.person_outline_rounded, - selectedIcon: Icons.person_rounded, + icon: Icons.video_library_outlined, + selectedIcon: Icons.video_library_rounded, label: l10n.myTab, selected: logic.tab == 2, - indicatorKey: const Key('tab-my-indicator'), onTap: () => _selectTab(context, 2, myProfile: true), ), ], diff --git a/test/theme/app_dimens_test.dart b/test/theme/app_dimens_test.dart index 149b85e..f4402d4 100644 --- a/test/theme/app_dimens_test.dart +++ b/test/theme/app_dimens_test.dart @@ -6,18 +6,18 @@ import 'package:fruitshorts/core/theme/app_dimens.dart'; void main() { test('should_expose_the_k20_spacing_and_type_scale', () { expect(AppDimens.pageHorizontal, 16); - expect(AppDimens.cardRadius, 16); - expect(AppDimens.innerRadius, 12); + expect(AppDimens.cardRadius, 12); + expect(AppDimens.innerRadius, 10); expect(AppDimens.caption, 12); expect(AppDimens.body, 14); expect(AppDimens.listTitle, 15); expect(AppDimens.sectionTitle, 16); expect(AppDimens.pageTitle, 20); - expect(AppDimens.navLabel, 11); + expect(AppDimens.navLabel, 9); expect(AppDimens.buttonHeight, 48); expect(AppDimens.primaryButtonHeight, 52); expect(AppDimens.tapTarget, 44); - expect(AppDimens.navHeight, 64); - expect(AppDimens.progressHeight, 4); + expect(AppDimens.navHeight, 54); + expect(AppDimens.progressHeight, 3); }); } diff --git a/test/theme/app_theme_test.dart b/test/theme/app_theme_test.dart index ea9b91c..4a7eb85 100644 --- a/test/theme/app_theme_test.dart +++ b/test/theme/app_theme_test.dart @@ -21,8 +21,10 @@ void main() { expect(AppColors.secondary, const Color(0xFF9278EA)); expect(AppColors.gold, const Color(0xFFFBBF24)); expect(AppColors.ink, const Color(0xFF0B0C10)); - expect(AppColors.card, const Color(0xFF16181F)); - expect(AppColors.raised, const Color(0xFF1D1E28)); + expect(AppColors.card, const Color(0xFF1B1C21)); + expect(AppColors.cardAlt, const Color(0xFF1C1D21)); + expect(AppColors.raised, const Color(0xFF1C1D21)); + expect(AppColors.bar, const Color(0xFF0D0D11)); expect(AppColors.success, const Color(0xFF34D399)); expect(AppColors.onPrimary, const Color(0xFFFFFFFF)); expect( From 21d457fd98bc4f3f26c3dafc15be19c9743a1b95 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 19 Oct 2026 15:50:11 +0800 Subject: [PATCH 2/4] =?UTF-8?q?feat(ui):=20=E6=A0=A1=E6=AD=A3=E9=A6=96?= =?UTF-8?q?=E9=A1=B5=E5=8F=91=E7=8E=B0=E8=AF=A6=E6=83=85=E6=92=AD=E6=94=BE?= =?UTF-8?q?=E5=99=A8=E9=A2=84=E8=A7=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/discover/discover_view.dart | 361 +++---- .../discover/widgets/home_drama_grid.dart | 157 ++- lib/pages/drama_detail/drama_detail_view.dart | 909 +++++------------- lib/pages/player/player_logic.dart | 12 + lib/pages/player/player_view.dart | 517 ++++++---- .../player/widgets/home_feed_overlay.dart | 554 +++-------- .../widgets/player_featured_comment.dart | 158 +++ .../player/widgets/seek_progress_bar.dart | 2 +- test/discover/home_all_dramas_grid_test.dart | 32 +- test/drama_detail/drama_detail_view_test.dart | 47 +- test/home/home_preview_layout_test.dart | 64 ++ test/player/player_fullscreen_test.dart | 27 +- test/widget_test.dart | 62 +- 13 files changed, 1218 insertions(+), 1684 deletions(-) create mode 100644 lib/pages/player/widgets/player_featured_comment.dart create mode 100644 test/home/home_preview_layout_test.dart diff --git a/lib/pages/discover/discover_view.dart b/lib/pages/discover/discover_view.dart index ae88d85..c468231 100644 --- a/lib/pages/discover/discover_view.dart +++ b/lib/pages/discover/discover_view.dart @@ -13,7 +13,6 @@ import '../../core/theme/app_dimens.dart'; import '../../core/theme/app_theme.dart'; import '../../core/widgets/app_bottom_nav.dart'; import '../../core/widgets/empty_state.dart'; -import '../../core/widgets/glass_panel.dart'; import '../../core/widgets/remote_image.dart'; import '../../l10n/app_localizations.dart'; import 'discover_binding.dart'; @@ -165,53 +164,41 @@ class _DiscoverPageState extends State { ), SliverToBoxAdapter( child: Padding( - padding: const EdgeInsets.fromLTRB(16, 2, 16, 12), + padding: const EdgeInsets.fromLTRB(14, 2, 14, 10), child: Row( children: [ + Container( + width: 3, + height: 18, + decoration: BoxDecoration( + color: AppColors.blue, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 6), Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - l10n.trendingMicroDramas, - style: const TextStyle( - fontFamily: 'Outfit', - color: AppColors.onSurface, - fontSize: 17, - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 2), - Text( - l10n.trendingSubtitle, - style: const TextStyle( - color: AppColors.subtle, - fontSize: 11, - ), - ), - ], + child: Text( + l10n.trendingMicroDramas, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: AppDimens.previewSectionTitle, + fontWeight: FontWeight.w800, + ), ), ), TextButton( onPressed: () => - categories.selectCode(CategoryController.hotCode), + categories.selectCode(CategoryController.allCode), style: TextButton.styleFrom( - foregroundColor: AppColors.blue, + foregroundColor: AppColors.accent, minimumSize: const Size(44, 44), - padding: const EdgeInsets.symmetric(horizontal: 8), + padding: EdgeInsets.zero, ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - l10n.seeAll, - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - ), - ), - const Icon(Icons.chevron_right_rounded, size: 16), - ], + child: Text( + '${l10n.all} ›', + style: const TextStyle(fontSize: 10), ), ), ], @@ -236,55 +223,41 @@ class _DiscoverPageState extends State { Widget _header(AppLocalizations l10n) { return Padding( - padding: const EdgeInsets.fromLTRB(16, 6, 16, 12), + padding: const EdgeInsets.fromLTRB(14, 2, 14, 10), child: Row( children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Container( - width: 8, - height: 8, - decoration: const BoxDecoration( - color: AppColors.blue, - shape: BoxShape.circle, - ), - ), - const SizedBox(width: 8), - Flexible( - child: Text( - l10n.appName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontFamily: 'Outfit', - color: AppColors.onSurface, - fontSize: 21, - fontWeight: FontWeight.w700, - height: 1.05, - letterSpacing: 0, - ), - ), - ), - ], - ), - const SizedBox(height: 3), - Text( - l10n.verticalMiniSeries, - style: const TextStyle( - color: AppColors.muted, - fontSize: 10, - fontWeight: FontWeight.w500, - letterSpacing: 0.6, - ), - ), - ], + 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: 12), + const SizedBox(width: 8), + Text( + l10n.discoverTab, + style: const TextStyle( + fontFamily: 'Outfit', + color: AppColors.onSurface, + fontSize: 15, + fontWeight: FontWeight.w800, + ), + ), + const Spacer(), + _HeaderIcon( + key: const Key('discover-search-button'), + icon: Icons.search_rounded, + tooltip: l10n.search, + onTap: logic.openSearch, + ), + const SizedBox(width: 7), Semantics( button: true, label: widget.signedIn ? l10n.myTab : l10n.logIn, @@ -293,20 +266,20 @@ class _DiscoverPageState extends State { customBorder: const CircleBorder(), onTap: widget.signedIn ? widget.onCoins : widget.onSignIn, child: Container( - width: 38, - height: 38, + width: 28, + height: 28, decoration: BoxDecoration( shape: BoxShape.circle, - color: AppColors.raised, - border: Border.all( - color: AppColors.secondary.withValues(alpha: 0.7), - width: 2, + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFF6A5CFF), AppColors.blue], ), ), child: const Icon( Icons.person_rounded, color: AppColors.onSurface, - size: 21, + size: 15, ), ), ), @@ -318,9 +291,9 @@ class _DiscoverPageState extends State { Widget _search() { return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), + padding: const EdgeInsets.symmetric(horizontal: 14), child: SizedBox( - height: AppDimens.searchHeight, + height: 36, child: TextField( key: const Key('discover-search'), controller: logic.search, @@ -331,27 +304,22 @@ class _DiscoverPageState extends State { decoration: InputDecoration( isDense: true, filled: true, - fillColor: AppColors.card, + fillColor: AppColors.input, hintText: AppLocalizations.of(context)!.searchDiscover, - hintStyle: const TextStyle(color: AppColors.subtle, fontSize: 12.5), + hintStyle: const TextStyle(color: AppColors.cream, fontSize: 11), prefixIcon: const Icon( Icons.search_rounded, color: AppColors.subtle, - size: 20, - ), - suffixIcon: const Icon( - Icons.mic_none_rounded, - color: AppColors.muted, - size: 19, + size: 17, ), contentPadding: const EdgeInsets.symmetric(horizontal: 12), enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(AppDimens.glassSmallRadius), - borderSide: const BorderSide(color: AppColors.border), + borderRadius: BorderRadius.circular(20), + borderSide: BorderSide.none, ), focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(AppDimens.glassSmallRadius), - borderSide: const BorderSide(color: AppColors.blue, width: 1.2), + borderRadius: BorderRadius.circular(20), + borderSide: const BorderSide(color: AppColors.blue, width: 1), ), ), ), @@ -361,9 +329,7 @@ class _DiscoverPageState extends State { Widget _categories(AppLocalizations l10n, CategoryController categories) { final choices = <({String code, String label})>[ - (code: CategoryController.allCode, label: l10n.all), - (code: CategoryController.hotCode, label: l10n.hot), - (code: CategoryController.newCode, label: l10n.newest), + (code: CategoryController.allCode, label: l10n.recommended), for (final category in categories.categories) (code: 'category-${category.id}', label: category.name), ]; @@ -371,7 +337,7 @@ class _DiscoverPageState extends State { key: const Key('home-category-chips'), height: AppDimens.tapTarget, child: ListView.separated( - padding: const EdgeInsets.symmetric(horizontal: 16), + padding: const EdgeInsets.symmetric(horizontal: 14), scrollDirection: Axis.horizontal, itemCount: choices.length, separatorBuilder: (_, _) => const SizedBox(width: 8), @@ -390,12 +356,10 @@ class _DiscoverPageState extends State { alignment: Alignment.center, padding: const EdgeInsets.symmetric(horizontal: 15), decoration: BoxDecoration( - color: selected ? AppColors.blue : AppColors.card, + color: selected ? AppColors.blue : const Color(0xFF29272B), borderRadius: BorderRadius.circular(999), border: Border.all( - color: selected - ? AppColors.blue - : Colors.white.withValues(alpha: 0.08), + color: selected ? AppColors.blue : Colors.transparent, ), ), child: Text( @@ -403,7 +367,7 @@ class _DiscoverPageState extends State { maxLines: 1, style: TextStyle( color: selected ? AppColors.onPrimary : AppColors.muted, - fontSize: 12, + fontSize: AppDimens.chipFont, fontWeight: selected ? FontWeight.w700 : FontWeight.w500, height: 1.1, ), @@ -423,23 +387,20 @@ class _DiscoverPageState extends State { WatchProgress record, ) { final progress = record.fraction; - return GlassPanel( + return Container( key: const Key('discover-continue-watching'), - radius: AppDimens.glassSmallRadius, - blur: 18, - padding: const EdgeInsets.all(12), - gradient: const LinearGradient( - begin: Alignment.centerLeft, - end: Alignment.centerRight, - colors: [Color(0x1FFFFFFF), Color(0x0AFFFFFF)], + padding: const EdgeInsets.all(9), + decoration: BoxDecoration( + color: AppColors.cardAlt, + borderRadius: BorderRadius.circular(12), ), child: Row( children: [ ClipRRect( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(10), child: SizedBox( - width: 64, - height: 88, + width: 52, + height: 66, child: RemoteImage( drama.posterUrl, fit: BoxFit.cover, @@ -447,57 +408,38 @@ class _DiscoverPageState extends State { ), ), ), - const SizedBox(width: 12), + const SizedBox(width: 9), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - children: [ - Container( - width: 6, - height: 6, - decoration: const BoxDecoration( - color: AppColors.blue, - shape: BoxShape.circle, - ), - ), - const SizedBox(width: 5), - Flexible( - child: Text( - l10n.continueWatching, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.blue, - fontSize: 9.5, - fontWeight: FontWeight.w700, - letterSpacing: 0.4, - ), - ), - ), - ], + Text( + l10n.continueWatching, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.accent, + fontSize: 8, + fontWeight: FontWeight.w800, + ), ), - const SizedBox(height: 3), + const SizedBox(height: 2), Text( drama.title, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.onSurface, - fontSize: 13, - fontWeight: FontWeight.w600, + fontSize: 12, + fontWeight: FontWeight.w800, ), ), const SizedBox(height: 2), Text( - _resumeMeta(drama, record), + _resumeShort(drama, record), maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.muted, - fontSize: 10.5, - ), + style: const TextStyle(color: Color(0xFFBBBBBB), fontSize: 9), ), const SizedBox(height: 7), ClipRRect( @@ -505,7 +447,7 @@ class _DiscoverPageState extends State { child: LinearProgressIndicator( value: progress <= 0 ? null : progress, minHeight: 3, - backgroundColor: Colors.white.withValues(alpha: 0.12), + backgroundColor: const Color(0xFF474248), valueColor: const AlwaysStoppedAnimation(AppColors.blue), ), ), @@ -525,29 +467,24 @@ class _DiscoverPageState extends State { style: FilledButton.styleFrom( backgroundColor: AppColors.blue, foregroundColor: AppColors.onPrimary, - minimumSize: const Size(64, 34), - padding: const EdgeInsets.symmetric(horizontal: 10), - shape: const StadiumBorder(), + minimumSize: const Size(56, 28), + padding: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(9), + ), textStyle: const TextStyle( - fontSize: 11, + fontSize: 10, fontWeight: FontWeight.w700, ), ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text(l10n.play), - const SizedBox(width: 2), - const Icon(Icons.play_arrow_rounded, size: 14), - ], - ), + child: Text(l10n.resume), ), ], ), ); } - String _resumeMeta(Drama drama, WatchProgress record) { + String _resumeShort(Drama drama, WatchProgress record) { final label = AppLocalizations.of(context)!; Episode? episode; for (final item in drama.episodes) { @@ -556,10 +493,8 @@ class _DiscoverPageState extends State { break; } } - final head = episode == null - ? '' - : '${label.episodeLabel(episode.number)} / ${drama.episodeCount} · '; - return '$head${_duration(record.position)} / ${_duration(record.duration)}'; + if (episode == null) return _duration(record.position); + return label.episodeOfTotal(episode.number, drama.episodeCount); } Widget _quickActions(AppLocalizations l10n, CategoryController categories) { @@ -574,7 +509,7 @@ class _DiscoverPageState extends State { onTap: logic.openSearch, ), ), - const SizedBox(width: 8), + const SizedBox(width: 7), Expanded( child: _QuickAction( key: const Key('discover-quick-charts'), @@ -584,17 +519,17 @@ class _DiscoverPageState extends State { onTap: () => categories.selectCode(CategoryController.hotCode), ), ), - const SizedBox(width: 8), + const SizedBox(width: 7), Expanded( child: _QuickAction( key: const Key('discover-quick-new'), icon: Icons.auto_awesome_rounded, - label: l10n.newest, + label: l10n.newReleases, color: AppColors.gold, onTap: () => categories.selectCode(CategoryController.newCode), ), ), - const SizedBox(width: 8), + const SizedBox(width: 7), Expanded( child: _QuickAction( key: const Key('discover-quick-watchlist'), @@ -617,8 +552,8 @@ class _DiscoverPageState extends State { items: [ AppBottomNavItem( tabKey: const Key('discover-nav-home'), - icon: Icons.home_outlined, - selectedIcon: Icons.home_rounded, + icon: Icons.play_circle_outline_rounded, + selectedIcon: Icons.play_circle_fill_rounded, label: l10n.homeTab, onTap: () => logic.act( context, @@ -637,8 +572,8 @@ class _DiscoverPageState extends State { ), AppBottomNavItem( tabKey: const Key('discover-nav-my'), - icon: Icons.person_outline_rounded, - selectedIcon: Icons.person_rounded, + icon: Icons.video_library_outlined, + selectedIcon: Icons.video_library_rounded, label: l10n.myTab, onTap: () => logic.act( context, @@ -651,6 +586,37 @@ class _DiscoverPageState extends State { ); } +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: 28, + height: 28, + child: IconButton( + tooltip: tooltip, + onPressed: onTap, + padding: EdgeInsets.zero, + style: IconButton.styleFrom( + backgroundColor: const Color(0xFF2C2D33), + foregroundColor: AppColors.onSurface, + ), + icon: Icon(icon, size: 14), + ), + ); + } +} + class _QuickAction extends StatelessWidget { const _QuickAction({ super.key, @@ -668,31 +634,20 @@ class _QuickAction extends StatelessWidget { @override Widget build(BuildContext context) { return Material( - color: AppColors.card, - borderRadius: BorderRadius.circular(AppDimens.glassSmallRadius), + color: AppColors.quick, + borderRadius: BorderRadius.circular(12), clipBehavior: Clip.antiAlias, child: InkWell( onTap: onTap, child: Container( - height: 76, - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(AppDimens.glassSmallRadius), - border: Border.all(color: AppColors.border), - ), + height: 62, + padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 5), + decoration: BoxDecoration(borderRadius: BorderRadius.circular(12)), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Container( - width: 32, - height: 32, - decoration: BoxDecoration( - color: color.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(10), - ), - child: Icon(icon, color: color, size: 17), - ), - const SizedBox(height: 5), + Icon(icon, color: color, size: 16), + const SizedBox(height: 4), Text( label, maxLines: 2, @@ -700,7 +655,7 @@ class _QuickAction extends StatelessWidget { overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.muted, - fontSize: 9.5, + fontSize: 9, fontWeight: FontWeight.w500, height: 1.05, ), diff --git a/lib/pages/discover/widgets/home_drama_grid.dart b/lib/pages/discover/widgets/home_drama_grid.dart index 015e269..f2a1ee0 100644 --- a/lib/pages/discover/widgets/home_drama_grid.dart +++ b/lib/pages/discover/widgets/home_drama_grid.dart @@ -37,27 +37,22 @@ class _HomeDramaGrid extends StatelessWidget { ), ); } - if (dramas.isEmpty) { - final missingCategory = categories.selectedSectionMissing; return _fill( EmptyState( key: const Key('home-all-dramas-empty'), icon: searching ? Icons.search_off_rounded - : missingCategory - ? Icons.grid_view_rounded : Icons.movie_filter_outlined, title: searching ? l10n.noSearchResults - : missingCategory + : categories.selectedSectionMissing ? l10n.noCategoryDramas : l10n.noSearchResults, subtitle: l10n.emptyHint, ), ); } - return SliverMainAxisGroup( slivers: [ _dramaGrid(dramas), @@ -68,14 +63,14 @@ class _HomeDramaGrid extends StatelessWidget { Widget _dramaGrid(List items) => SliverPadding( key: const Key('home-all-dramas-section'), - padding: const EdgeInsets.symmetric(horizontal: AppDimens.pageHorizontal), + padding: const EdgeInsets.symmetric(horizontal: 14), sliver: SliverGrid( key: const Key('home-all-dramas-grid'), gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 2, - mainAxisSpacing: 14, - crossAxisSpacing: 12, - childAspectRatio: 0.55, + mainAxisSpacing: 10, + crossAxisSpacing: 10, + mainAxisExtent: 164, ), delegate: SliverChildBuilderDelegate( (context, index) => _HomeDramaCard( @@ -88,9 +83,8 @@ class _HomeDramaGrid extends StatelessWidget { ), ); - Widget _fill(Widget child) { - return SliverFillRemaining(hasScrollBody: false, child: child); - } + Widget _fill(Widget child) => + SliverFillRemaining(hasScrollBody: false, child: child); } class _HomeDramaCard extends StatelessWidget { @@ -107,18 +101,21 @@ class _HomeDramaCard extends StatelessWidget { @override Widget build(BuildContext context) { final genre = drama.genre.isEmpty ? drama.category : drama.genre; + final free = + drama.freeEpisodes > 0 || + drama.episodes.any((episode) => episode.isFree); return InkWell( key: Key('home-drama-card-${drama.id}'), onTap: onTap, - borderRadius: BorderRadius.circular(AppDimens.cardRadius), + borderRadius: BorderRadius.circular(10), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - AspectRatio( + SizedBox( key: Key('home-drama-cover-${drama.id}'), - aspectRatio: 3 / 4.2, + height: 120, child: ClipRRect( - borderRadius: BorderRadius.circular(AppDimens.cardRadius), + borderRadius: BorderRadius.circular(10), child: Stack( fit: StackFit.expand, children: [ @@ -132,120 +129,76 @@ class _HomeDramaCard extends StatelessWidget { gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, - colors: [ - Color(0x33000000), - Color(0x00000000), - Color(0xB3000000), - ], - stops: [0, 0.42, 1], + colors: [Color(0x0D000000), Color(0xCC000000)], + stops: [0.45, 1], ), ), ), - Positioned( - top: 8, - left: 8, - child: Wrap( - spacing: 4, - runSpacing: 4, - children: [ - if (drama.isHot) _badge(l10n.hot, AppColors.blue), - if (drama.freeEpisodes > 0) - _badge(l10n.free, AppColors.success), - ], - ), - ), - if (drama.viewCount > 0) + if (free) Positioned( - right: 8, - bottom: 8, - child: DecoratedBox( - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.42), - borderRadius: BorderRadius.circular(7), + top: 6, + left: 6, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, ), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - child: Text( - _compactCount(drama.viewCount), - key: Key('home-drama-views-${drama.id}'), - style: const TextStyle( - color: AppColors.cream, - fontSize: 10, - fontWeight: FontWeight.w600, - ), + decoration: BoxDecoration( + color: AppColors.blue, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + l10n.free, + style: const TextStyle( + color: AppColors.onPrimary, + fontSize: 8, + fontWeight: FontWeight.w800, ), ), ), ), + Positioned( + left: 6, + right: 6, + bottom: 6, + child: Text( + drama.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onPrimary, + fontSize: 9, + fontWeight: FontWeight.w800, + shadows: [Shadow(blurRadius: 6, color: Colors.black)], + ), + ), + ), ], ), ), ), - const SizedBox(height: 8), + const SizedBox(height: 5), Text( drama.title, key: Key('home-drama-title-${drama.id}'), - maxLines: 2, + maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.onSurface, - fontSize: 13, - fontWeight: FontWeight.w600, - height: 1.2, + fontSize: 11, + fontWeight: FontWeight.w800, ), ), - const SizedBox(height: 3), + const SizedBox(height: 2), Text( genre.isEmpty ? l10n.videoPending : genre, key: Key('home-drama-genre-${drama.id}'), maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.subtle, - fontSize: 11, - fontWeight: FontWeight.w400, - height: 1.2, - ), + style: const TextStyle(color: Color(0xFFBBBBBB), fontSize: 9), ), ], ), ); } - - Widget _badge(String label, Color color) { - return DecoratedBox( - decoration: BoxDecoration( - color: color, - borderRadius: BorderRadius.circular(999), - ), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2), - child: Text( - label, - style: const TextStyle( - color: AppColors.onPrimary, - fontSize: 9, - fontWeight: FontWeight.w800, - letterSpacing: 0.3, - height: 1.1, - ), - ), - ), - ); - } -} - -String _compactCount(int value) { - if (value >= 1000000) { - final scaled = value / 1000000; - return '${scaled.toStringAsFixed(scaled >= 10 ? 0 : 1)}M'; - } - if (value >= 1000) { - final scaled = value / 1000; - return '${scaled.toStringAsFixed(scaled >= 10 ? 0 : 1)}K'; - } - return '$value'; } diff --git a/lib/pages/drama_detail/drama_detail_view.dart b/lib/pages/drama_detail/drama_detail_view.dart index f358240..fc8d774 100644 --- a/lib/pages/drama_detail/drama_detail_view.dart +++ b/lib/pages/drama_detail/drama_detail_view.dart @@ -21,14 +21,16 @@ import '../../core/theme/app_theme.dart'; import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/download_action.dart'; import '../../core/widgets/empty_state.dart'; -import '../../core/widgets/glass_panel.dart'; import '../../core/widgets/remote_image.dart'; import '../../l10n/app_localizations.dart'; import '../interaction/widgets/report_dialog.dart'; import 'drama_detail_binding.dart'; import 'drama_detail_logic.dart'; -const String _reportMenuValue = 'report'; +const String _menuFavorite = 'favorite'; +const String _menuDownload = 'download'; +const String _menuShare = 'share'; +const String _menuReport = 'report'; class DramaDetailPage extends StatefulWidget { const DramaDetailPage({super.key, required this.drama}); @@ -41,7 +43,6 @@ class DramaDetailPage extends StatefulWidget { class _DramaDetailPageState extends State { late final DramaDetailLogic logic; - bool _synopsisExpanded = false; @override void initState() { @@ -93,13 +94,8 @@ class _DramaDetailPageState extends State { key: const Key('detail-scroll'), slivers: [ SliverToBoxAdapter(child: _hero(context, l10n, drama)), - SliverToBoxAdapter(child: _metadata(l10n, drama)), - SliverToBoxAdapter(child: _synopsis(l10n, drama)), - SliverToBoxAdapter(child: _primaryActions(context, l10n)), - SliverToBoxAdapter(child: _episodeHeader(l10n, drama)), - SliverToBoxAdapter(child: _episodeChips(drama)), - _episodeGrid(context, l10n, drama), - const SliverToBoxAdapter(child: SizedBox(height: 32)), + SliverToBoxAdapter(child: _content(context, l10n, drama)), + const SliverToBoxAdapter(child: SizedBox(height: 28)), ], ), ); @@ -159,314 +155,115 @@ class _DramaDetailPageState extends State { ); Widget _hero(BuildContext context, AppLocalizations l10n, Drama drama) { - return Stack( - children: [ - AspectRatio( - key: const Key('detail-hero'), - aspectRatio: 16 / 9, - child: Stack( - fit: StackFit.expand, - children: [ - RemoteImage( - drama.posterUrl, - fit: BoxFit.cover, - showFallbackIcon: true, - ), - const DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Color(0x260B0C10), - Color(0x000B0C10), - AppColors.ink, - ], - stops: [0, 0.5, 1], - ), - ), - ), - ], + return SizedBox( + key: const Key('detail-hero'), + height: 190, + child: Stack( + fit: StackFit.expand, + children: [ + RemoteImage( + drama.posterUrl, + fit: BoxFit.cover, + showFallbackIcon: true, ), - ), - Positioned( - top: MediaQuery.paddingOf(context).top + 8, - left: 16, - right: 16, - child: Row( - children: [ - _GlassCircleButton( - key: const Key('detail-back'), - tooltip: MaterialLocalizations.of(context).backButtonTooltip, - icon: Icons.chevron_left_rounded, - onPressed: () => Navigator.of(context).maybePop(), + const DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0x33000000), Color(0xCC0B0C10)], ), - const Spacer(), - _GlassCircleButton( - key: const Key('detail-share'), - tooltip: l10n.share, - icon: Icons.share_outlined, - onPressed: () => unawaited(_shareDrama(l10n, drama)), - ), - const SizedBox(width: 8), - _favoriteButton( - context, - l10n, - key: const Key('detail-favorite-top'), - ), - const SizedBox(width: 8), - _reportAction(context, l10n), - ], + ), ), - ), - ], + Positioned( + top: MediaQuery.paddingOf(context).top + 14, + left: 14, + child: _RoundAction( + key: const Key('detail-back'), + tooltip: MaterialLocalizations.of(context).backButtonTooltip, + icon: Icons.chevron_left_rounded, + onPressed: () => Navigator.of(context).maybePop(), + ), + ), + Positioned( + top: MediaQuery.paddingOf(context).top + 14, + right: 14, + child: _moreMenu(context, l10n), + ), + ], + ), ); } - Widget _metadata(AppLocalizations l10n, Drama drama) { - final genres = [ - if (drama.genre.isNotEmpty) drama.genre, - if (drama.category.isNotEmpty) drama.category, - ]; + Widget _content(BuildContext context, AppLocalizations l10n, Drama drama) { + final continueIndex = logic.continueIndex; return Padding( - padding: const EdgeInsets.fromLTRB(20, 2, 20, 0), + padding: const EdgeInsets.fromLTRB(16, 14, 16, 0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( + Text( + l10n.episodeCount(drama.episodeCount), key: const Key('detail-episode-count'), - padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4), - decoration: BoxDecoration( - color: AppColors.secondary.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(999), - border: Border.all( - color: AppColors.secondary.withValues(alpha: 0.3), - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox( - width: 6, - height: 6, - child: DecoratedBox( - decoration: BoxDecoration( - color: AppColors.secondary, - shape: BoxShape.circle, - ), - ), - ), - const SizedBox(width: 5), - Text( - l10n.episodeCount(drama.episodeCount), - style: const TextStyle( - color: AppColors.secondary, - fontSize: 10, - fontWeight: FontWeight.w700, - letterSpacing: 0.5, - ), - ), - ], + style: const TextStyle( + color: AppColors.secondary, + fontSize: 9, + fontWeight: FontWeight.w800, + letterSpacing: 1, ), ), - const SizedBox(height: 10), + const SizedBox(height: 7), Text( localizedDramaTitle(l10n, drama), key: const Key('detail-title'), style: const TextStyle( fontFamily: 'Outfit', color: AppColors.onSurface, - fontSize: 24, - fontWeight: FontWeight.w700, - height: 1.15, - letterSpacing: 0, - ), - ), - const SizedBox(height: 5), - Text( - l10n.seasonOneOriginalRelease, - style: const TextStyle( - color: AppColors.muted, - fontSize: 11, - fontWeight: FontWeight.w500, + fontSize: 18, + fontWeight: FontWeight.w800, + height: 1.2, ), ), const SizedBox(height: 10), - Wrap( - spacing: 7, - runSpacing: 7, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - if (drama.viewCount > 0) - _metaPill( - icon: Icons.local_fire_department_rounded, - label: '${_compactCount(drama.viewCount)} ${l10n.views}', - color: AppColors.blue, - ), - for (final genre in genres) - _metaPill(label: genre, color: AppColors.cream), - ], - ), - ], - ), - ); - } - - Widget _metaPill({ - IconData? icon, - required String label, - required Color color, - }) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.05), - borderRadius: BorderRadius.circular(7), - border: Border.all(color: AppColors.border), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (icon != null) ...[ - Icon(icon, size: 13, color: color), - const SizedBox(width: 4), - ], Text( - label, - style: TextStyle( - color: color, + localizedDramaSynopsis(l10n, drama), + key: const Key('detail-synopsis'), + style: const TextStyle( + color: AppColors.muted, fontSize: 11, - fontWeight: FontWeight.w500, + height: 1.6, ), ), - ], - ), - ); - } - - Widget _synopsis(AppLocalizations l10n, Drama drama) { - final text = localizedDramaSynopsis(l10n, drama); - return Padding( - padding: const EdgeInsets.fromLTRB(20, 14, 20, 0), - child: GlassPanel( - key: const Key('detail-synopsis-card'), - radius: AppDimens.glassSmallRadius, - blur: 12, - padding: const EdgeInsets.fromLTRB(13, 11, 13, 9), - gradient: const LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [Color(0x1A12141A), Color(0x3312141A)], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - text, - key: const Key('detail-synopsis'), - maxLines: _synopsisExpanded ? 100 : 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.muted, - fontSize: 13, - height: 1.45, + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + height: 40, + child: FilledButton.icon( + key: const Key('detail-cta'), + onPressed: () => logic.openPlayer(context, continueIndex ?? 0), + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: AppColors.onPrimary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppDimens.buttonRadius), + ), + textStyle: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), + icon: const Icon(Icons.play_arrow_rounded, size: 18), + label: Text( + continueIndex == null + ? l10n.startWatching + : l10n.continueEpisode(continueIndex + 1), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), ), - Align( - alignment: Alignment.centerRight, - child: TextButton( - key: const Key('detail-synopsis-toggle'), - onPressed: () => - setState(() => _synopsisExpanded = !_synopsisExpanded), - style: TextButton.styleFrom( - foregroundColor: AppColors.secondary, - minimumSize: const Size(44, 32), - padding: const EdgeInsets.symmetric(horizontal: 4), - textStyle: const TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text(_synopsisExpanded ? l10n.showLess : l10n.more), - Icon( - _synopsisExpanded - ? Icons.keyboard_arrow_up_rounded - : Icons.keyboard_arrow_down_rounded, - size: 16, - ), - ], - ), - ), - ), - ], - ), - ), - ); - } - - Widget _primaryActions(BuildContext context, AppLocalizations l10n) { - final continueIndex = logic.continueIndex; - return Padding( - padding: const EdgeInsets.fromLTRB(20, 12, 20, 0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - children: [ - Expanded( - child: SizedBox( - height: 52, - child: FilledButton.icon( - key: const Key('detail-cta'), - onPressed: () => - logic.openPlayer(context, continueIndex ?? 0), - style: FilledButton.styleFrom( - backgroundColor: AppColors.blue, - foregroundColor: AppColors.onPrimary, - shape: const StadiumBorder(), - textStyle: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w700, - ), - ), - icon: const Icon(Icons.play_arrow_rounded, size: 20), - label: Text( - continueIndex == null - ? l10n.startWatching - : l10n.continueEpisode(continueIndex + 1), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ), - ), - const SizedBox(width: 10), - _squareAction( - key: const Key('detail-favorite'), - tooltip: l10n.favorite, - icon: - context.watch().isSaved(logic.drama.id) - ? Icons.bookmark_rounded - : Icons.bookmark_border_rounded, - selected: context.watch().isSaved( - logic.drama.id, - ), - onPressed: () => _toggleFavorite(l10n), - ), - const SizedBox(width: 10), - _squareAction( - key: const Key('detail-download'), - tooltip: l10n.downloads, - icon: Icons.download_outlined, - onPressed: _downloadCurrent, - ), - ], ), if (_continueFraction != null) ...[ - const SizedBox(height: 9), + const SizedBox(height: 8), _ResumeBar( fraction: _continueFraction!, label: l10n.resumeProgress( @@ -484,7 +281,7 @@ class _DramaDetailPageState extends State { errorText(l10n, logic.progressError, l10n.progressError), style: const TextStyle( color: AppColors.muted, - fontSize: 12, + fontSize: 11, ), ), ), @@ -495,393 +292,159 @@ class _DramaDetailPageState extends State { ], ), ], - ], - ), - ); - } - - Widget _episodeHeader(AppLocalizations l10n, Drama drama) { - return Padding( - padding: const EdgeInsets.fromLTRB(20, 22, 20, 10), - child: Row( - children: [ + const SizedBox(height: 18), Text( l10n.episodes, style: const TextStyle( fontFamily: 'Outfit', color: AppColors.onSurface, - fontSize: 17, - fontWeight: FontWeight.w700, + fontSize: 14, + fontWeight: FontWeight.w800, ), ), - const SizedBox(width: 5), - Text( - '(${drama.episodeCount})', - style: const TextStyle( - color: AppColors.subtle, - fontSize: 12, - fontWeight: FontWeight.w600, - ), - ), - const Spacer(), - Container( - key: const Key('detail-episodes-range'), - padding: const EdgeInsets.symmetric(horizontal: 11, vertical: 5), - decoration: BoxDecoration( - color: AppColors.card, - borderRadius: BorderRadius.circular(999), - border: Border.all(color: AppColors.border), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - l10n.all, - style: const TextStyle( - color: AppColors.muted, - fontSize: 11, - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(width: 3), - const Icon( - Icons.keyboard_arrow_down_rounded, - size: 15, - color: AppColors.muted, - ), - ], - ), + const SizedBox(height: 8), + Wrap( + key: const Key('detail-episode-chips'), + spacing: 6, + runSpacing: 6, + children: [ + for (var index = 0; index < drama.episodes.length; index++) + _episodeChip(context, drama, index, continueIndex), + ], ), ], ), ); } - Widget _episodeChips(Drama drama) { - final selected = logic.continueIndex ?? 0; - return SizedBox( - key: const Key('detail-episode-chips'), - height: 44, - child: ListView.separated( - padding: const EdgeInsets.symmetric(horizontal: 20), - scrollDirection: Axis.horizontal, - itemCount: drama.episodes.length, - separatorBuilder: (_, _) => const SizedBox(width: 8), - itemBuilder: (context, index) { - final episode = drama.episodes[index]; - final active = index == selected; - final locked = episode.locked || (!episode.isFree && index > 0); - return InkWell( - key: Key('detail-episode-chip-${episode.number}'), - onTap: () => logic.openPlayer(context, index), - borderRadius: BorderRadius.circular(12), - child: Container( - width: 46, - height: 44, - alignment: Alignment.center, - decoration: BoxDecoration( - color: active - ? AppColors.blue - : locked - ? AppColors.secondary.withValues(alpha: 0.16) - : AppColors.card, - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: active - ? AppColors.blue - : locked - ? AppColors.secondary.withValues(alpha: 0.35) - : AppColors.border, - ), - ), - child: Text( - '${episode.number}', - style: TextStyle( - color: active - ? AppColors.onPrimary - : locked - ? AppColors.secondary - : AppColors.muted, - fontSize: 13, - fontWeight: FontWeight.w700, - ), - ), - ), - ); - }, - ), - ); - } - - Widget _episodeGrid( + Widget _episodeChip( BuildContext context, - AppLocalizations l10n, Drama drama, - ) { - return SliverPadding( - key: const Key('detail-episode-grid'), - padding: const EdgeInsets.fromLTRB(20, 14, 20, 0), - sliver: SliverGrid( - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - mainAxisSpacing: 10, - crossAxisSpacing: 10, - mainAxisExtent: 108, - ), - delegate: SliverChildBuilderDelegate( - (context, index) => - _episodeCard(context, l10n, drama, drama.episodes[index], index), - childCount: drama.episodes.length, - ), - ), - ); - } - - Widget _episodeCard( - BuildContext context, - AppLocalizations l10n, - Drama drama, - Episode episode, int index, + int? selectedIndex, ) { + final episode = drama.episodes[index]; + final selected = index == (selectedIndex ?? 0); final locked = episode.locked || (!episode.isFree && index > 0); - return Material( - key: Key('detail-episode-card-${episode.number}'), - color: AppColors.card, - borderRadius: BorderRadius.circular(16), - clipBehavior: Clip.antiAlias, - child: InkWell( - onTap: () => logic.openPlayer(context, index), - child: LayoutBuilder( - builder: (context, constraints) { - final thumbWidth = (constraints.maxWidth - 70).clamp(58.0, 96.0); - return Padding( - padding: const EdgeInsets.all(8), - child: Row( - children: [ - SizedBox( - key: Key('detail-episode-thumb-${episode.number}'), - width: thumbWidth, - height: 64, - child: ClipRRect( - borderRadius: BorderRadius.circular(10), - child: Stack( - fit: StackFit.expand, - children: [ - RemoteImage( - drama.posterUrl, - fit: BoxFit.cover, - showFallbackIcon: true, - ), - const DecoratedBox( - decoration: BoxDecoration(color: Color(0x33000000)), - ), - Center( - child: Container( - width: 24, - height: 24, - decoration: BoxDecoration( - color: locked - ? Colors.black54 - : AppColors.blue.withValues(alpha: 0.9), - shape: BoxShape.circle, - ), - child: Icon( - locked - ? Icons.lock_rounded - : Icons.play_arrow_rounded, - size: 14, - color: AppColors.onPrimary, - ), - ), - ), - if (episode.durationSec > 0) - Positioned( - right: 4, - bottom: 4, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 4, - vertical: 1, - ), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.68), - borderRadius: BorderRadius.circular(4), - ), - child: Text( - _clock( - Duration(seconds: episode.durationSec), - ), - style: const TextStyle( - color: AppColors.cream, - fontSize: 8, - fontWeight: FontWeight.w600, - ), - ), - ), - ), - ], - ), - ), - ), - const SizedBox(width: 8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Wrap( - spacing: 4, - runSpacing: 2, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - _episodeBadge( - episode.isFree && !locked - ? l10n.free - : l10n.coins, - locked ? AppColors.secondary : AppColors.success, - ), - Text( - l10n.episodeLabel(episode.number), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.subtle, - fontSize: 9.5, - fontWeight: FontWeight.w500, - ), - ), - ], - ), - const SizedBox(height: 4), - Text( - localizedEpisodeTitle(l10n, episode.number), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: locked - ? AppColors.muted - : AppColors.onSurface, - fontSize: 11.5, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 3), - GestureDetector( - onTap: () => - requestEpisodeDownload(context, episode.id), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.download_outlined, - size: 12, - color: AppColors.subtle, - ), - const SizedBox(width: 3), - Flexible( - child: Text( - l10n.downloads, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.subtle, - fontSize: 9, - ), - ), - ), - ], - ), - ), - ], - ), - ), - ], - ), - ); - }, + return InkWell( + key: Key('detail-episode-chip-${episode.number}'), + onTap: () => logic.openPlayer(context, index), + borderRadius: BorderRadius.circular(20), + child: Container( + constraints: const BoxConstraints(minWidth: 32, minHeight: 32), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + alignment: Alignment.center, + decoration: BoxDecoration( + color: selected + ? AppColors.blue + : locked + ? AppColors.secondary.withValues(alpha: 0.16) + : const Color(0xFF29272B), + borderRadius: BorderRadius.circular(20), ), - ), - ); - } - - Widget _episodeBadge(String label, Color color) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.14), - borderRadius: BorderRadius.circular(5), - border: Border.all(color: color.withValues(alpha: 0.28)), - ), - child: Text( - label, - style: TextStyle( - color: color, - fontSize: 8.5, - fontWeight: FontWeight.w700, - ), - ), - ); - } - - Widget _squareAction({ - required Key key, - required String tooltip, - required IconData icon, - required VoidCallback onPressed, - bool selected = false, - }) { - return SizedBox( - key: key, - width: 48, - height: 48, - child: IconButton( - tooltip: tooltip, - onPressed: onPressed, - style: IconButton.styleFrom( - backgroundColor: selected ? AppColors.raised : AppColors.card, - foregroundColor: selected ? AppColors.blue : AppColors.cream, - side: BorderSide( - color: selected ? AppColors.blue : AppColors.outlineDark, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), + child: Text( + '${episode.number}', + style: TextStyle( + color: selected + ? AppColors.onPrimary + : locked + ? AppColors.secondary + : AppColors.muted, + fontSize: 11, + fontWeight: FontWeight.w700, ), ), - icon: Icon(icon, size: 21), ), ); } - Widget _favoriteButton( - BuildContext context, - AppLocalizations l10n, { - required Key key, - }) { + Widget _moreMenu(BuildContext context, AppLocalizations l10n) { final saved = context.watch().isSaved(logic.drama.id); - return _GlassCircleButton( - key: key, - tooltip: l10n.favorite, - icon: saved ? Icons.bookmark_rounded : Icons.bookmark_border_rounded, - selected: saved, - onPressed: () => _toggleFavorite(l10n), - ); - } - - Widget _reportAction(BuildContext context, AppLocalizations l10n) { return PopupMenuButton( key: const Key('detail-more'), tooltip: l10n.more, color: AppColors.card, - onSelected: (_) => unawaited(_reportDrama(l10n)), + onSelected: (value) { + switch (value) { + case _menuFavorite: + unawaited(_toggleFavorite(l10n)); + case _menuDownload: + _downloadCurrent(); + case _menuShare: + unawaited(_shareDrama(l10n, logic.drama)); + case _menuReport: + unawaited(_reportDrama(l10n)); + } + }, itemBuilder: (_) => [ - PopupMenuItem(value: _reportMenuValue, child: Text(l10n.reportDrama)), + PopupMenuItem( + key: const Key('detail-favorite'), + value: _menuFavorite, + child: Row( + children: [ + Icon( + saved ? Icons.star_rounded : Icons.star_border_rounded, + color: saved ? AppColors.blue : AppColors.muted, + size: 18, + ), + const SizedBox(width: 9), + Text(l10n.favorite), + ], + ), + ), + PopupMenuItem( + key: const Key('detail-download'), + value: _menuDownload, + child: Row( + children: [ + const Icon( + Icons.download_outlined, + color: AppColors.muted, + size: 18, + ), + const SizedBox(width: 9), + Text(l10n.downloads), + ], + ), + ), + PopupMenuItem( + key: const Key('detail-share'), + value: _menuShare, + child: Row( + children: [ + const Icon( + Icons.share_outlined, + color: AppColors.muted, + size: 18, + ), + const SizedBox(width: 9), + Text(l10n.share), + ], + ), + ), + PopupMenuItem( + key: const Key('detail-report'), + value: _menuReport, + child: Row( + children: [ + const Icon(Icons.flag_outlined, color: AppColors.muted, size: 18), + const SizedBox(width: 9), + Text(l10n.reportDrama), + ], + ), + ), ], - child: const _GlassCircle( - child: Icon( + child: Container( + width: 28, + height: 28, + decoration: const BoxDecoration( + color: Color(0x80000000), + shape: BoxShape.circle, + ), + child: const Icon( Icons.more_horiz_rounded, + size: 18, color: AppColors.onSurface, - size: 20, ), ), ); @@ -958,52 +521,37 @@ class _DramaDetailPageState extends State { } } -class _GlassCircleButton extends StatelessWidget { - const _GlassCircleButton({ +class _RoundAction extends StatelessWidget { + const _RoundAction({ super.key, required this.tooltip, required this.icon, required this.onPressed, - this.selected = false, }); final String tooltip; final IconData icon; - final VoidCallback onPressed; - final bool selected; + final VoidCallback? onPressed; @override Widget build(BuildContext context) { - return _GlassCircle( + return Container( + width: 28, + height: 28, + decoration: const BoxDecoration( + color: Color(0x80000000), + shape: BoxShape.circle, + ), child: IconButton( - tooltip: tooltip, + tooltip: tooltip.isEmpty ? null : tooltip, onPressed: onPressed, padding: EdgeInsets.zero, - icon: Icon( - icon, - size: 20, - color: selected ? AppColors.blue : AppColors.onSurface, - ), + icon: Icon(icon, size: 18, color: AppColors.onSurface), ), ); } } -class _GlassCircle extends StatelessWidget { - const _GlassCircle({required this.child}); - - final Widget child; - - @override - Widget build(BuildContext context) { - return SizedBox( - width: 40, - height: 40, - child: GlassPanel(radius: 999, blur: 14, child: child), - ); - } -} - class _ResumeBar extends StatelessWidget { const _ResumeBar({required this.fraction, this.label}); @@ -1016,11 +564,11 @@ class _ResumeBar extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ ClipRRect( - borderRadius: BorderRadius.circular(AppDimens.progressRadius), + borderRadius: BorderRadius.circular(2), child: LinearProgressIndicator( value: fraction, - minHeight: AppDimens.progressLineHeight, - backgroundColor: Colors.white24, + minHeight: 3, + backgroundColor: const Color(0xFF474248), valueColor: const AlwaysStoppedAnimation(AppColors.blue), ), ), @@ -1028,25 +576,10 @@ class _ResumeBar extends StatelessWidget { const SizedBox(height: 4), Text( label!, - style: const TextStyle( - color: AppColors.muted, - fontSize: AppDimens.caption, - ), + style: const TextStyle(color: AppColors.muted, fontSize: 10), ), ], ], ); } } - -String _compactCount(int value) { - if (value >= 1000000) { - final scaled = value / 1000000; - return '${scaled.toStringAsFixed(scaled >= 10 ? 0 : 1)}M'; - } - if (value >= 1000) { - final scaled = value / 1000; - return '${scaled.toStringAsFixed(scaled >= 10 ? 0 : 1)}K'; - } - return '$value'; -} diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index aa4e50b..64c459e 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -531,6 +531,18 @@ class PlayerLogic extends ChangeNotifier { : null; } + bool get hasNextEpisode => nextVisibleIndex(session.currentIndex) != null; + + Future playNextEpisode() async { + final next = nextVisibleIndex(session.currentIndex); + if (next == null || !pages.hasClients) return; + await pages.animateToPage( + visibleIndices.indexOf(next), + duration: const Duration(milliseconds: 250), + curve: Curves.easeOutCubic, + ); + } + void beginScrub(VideoPlayerPort player) { if (session.active != player) return; scrubbingPlayer = player; diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index a195c69..63a1b63 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -18,13 +18,13 @@ import '../../core/formatting/localized_content.dart'; import '../../core/platform/haptics.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_theme.dart'; -import '../../core/widgets/app_bottom_nav.dart'; import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/download_action.dart'; import '../../l10n/app_localizations.dart'; import 'player_binding.dart'; import 'player_logic.dart'; import 'widgets/home_feed_overlay.dart'; +import 'widgets/player_featured_comment.dart'; import 'widgets/seek_progress_bar.dart'; import '../../core/data/repositories/watch_history_repository.dart'; import '../../core/data/repositories/playback_repository.dart'; @@ -340,7 +340,6 @@ class _PlayerPageState extends State with WidgetsBindingObserver { l10n: l10n, drama: drama, episode: episode, - controller: ready ? videoPort!.controller : null, liked: interactions.isLiked(drama.id), saved: context.watch().isSaved(drama.id), onLike: () async { @@ -381,10 +380,6 @@ class _PlayerPageState extends State with WidgetsBindingObserver { onPip: logic.pictureInPicture.isPlatformSupported ? () => unawaited(logic.enterPip(context)) : null, - onScrubStart: ready ? () => logic.beginScrub(videoPort!) : null, - onSeek: ready - ? (position) => logic.seekTo(context, videoPort!, position) - : null, ) else if (!logic.screenCleared) _PlaybackOverlay( @@ -398,9 +393,13 @@ class _PlayerPageState extends State with WidgetsBindingObserver { onBack: widget.embedded ? null : () => unawaited(logic.leave(context)), - onDiscover: () => logic.showDiscover(context), onEpisodes: () => logic.showEpisodes(context), - onMy: () => logic.showMy(context), + onNext: logic.hasNextEpisode + ? () => unawaited(logic.playNextEpisode()) + : null, + playing: logic.session.userWantsPlay, + onTogglePlay: () => + logic.session.setUserWantsPlay(!logic.session.userWantsPlay), // 收藏/点赞要对到具体账号:未登录先引导登录(首页/详情仍可匿名浏览) onLike: () async { if (await ensureSignedIn(context)) { @@ -434,7 +433,6 @@ class _PlayerPageState extends State with WidgetsBindingObserver { onDownload: () => requestEpisodeDownload(context, episode.id), onComments: () => logic.showComments(context, drama), onShare: () => logic.shareDrama(context, drama), - onClear: () => logic.setScreenCleared(true), onPip: logic.pictureInPicture.isPlatformSupported ? () => unawaited(logic.enterPip(context)) : null, @@ -442,7 +440,6 @@ class _PlayerPageState extends State with WidgetsBindingObserver { onSeek: ready ? (position) => logic.seekTo(context, videoPort!, position) : null, - showBottomNav: false, ), if (logic.screenCleared) SafeArea( @@ -649,20 +646,19 @@ class _PlaybackOverlay extends StatelessWidget { required this.controller, required this.liked, required this.saved, + required this.playing, + required this.onTogglePlay, required this.onBack, - required this.onDiscover, required this.onEpisodes, - required this.onMy, + required this.onComments, required this.onLike, required this.onSave, required this.onDownload, - required this.onComments, required this.onShare, - required this.onClear, required this.onPip, required this.onScrubStart, required this.onSeek, - required this.showBottomNav, + this.onNext, }); final AppLocalizations l10n; @@ -671,230 +667,333 @@ class _PlaybackOverlay extends StatelessWidget { final VideoPlayerController? controller; final bool liked; final bool saved; + final bool playing; + final VoidCallback onTogglePlay; final VoidCallback? onBack; - final VoidCallback onDiscover; final VoidCallback onEpisodes; - final VoidCallback onMy; + final VoidCallback onComments; + final VoidCallback? onNext; final VoidCallback onLike; final VoidCallback onSave; final VoidCallback onDownload; - final VoidCallback onComments; final VoidCallback onShare; - final VoidCallback onClear; final VoidCallback? onPip; final VoidCallback? onScrubStart; final Future Function(Duration)? onSeek; - final bool showBottomNav; @override - Widget build(BuildContext context) => SafeArea( - bottom: showBottomNav, - child: Stack( - children: [ - Column( + Widget build(BuildContext context) { + return SafeArea( + child: Column( + children: [ + _topBar(context), + Expanded( + child: Stack( + children: [ + Center( + child: GestureDetector( + key: const Key('player-play-toggle'), + onTap: onTogglePlay, + child: Container( + width: 52, + height: 52, + decoration: const BoxDecoration( + color: Color(0x80000000), + shape: BoxShape.circle, + ), + child: Icon( + playing + ? Icons.pause_rounded + : Icons.play_arrow_rounded, + color: AppColors.onPrimary, + size: 26, + ), + ), + ), + ), + const Positioned( + right: 10, + bottom: 14, + child: Text( + '1.0x', + style: TextStyle(color: Color(0xFFDDDDDD), fontSize: 9), + ), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(14, 0, 14, 12), + child: Column( + children: [ + SeekProgressBar( + valueListenable: controller, + semanticsLabel: l10n.seekVideo, + showTimes: false, + onScrubStart: onScrubStart, + onSeek: onSeek, + ), + const SizedBox(height: 6), + Row( + children: [ + Expanded( + child: _PlayerAction( + key: const Key('player-episodes'), + icon: Icons.view_list_rounded, + label: l10n.episodes, + onTap: onEpisodes, + ), + ), + const SizedBox(width: 8), + Expanded( + child: _PlayerAction( + key: const Key('player-comments'), + icon: Icons.chat_bubble_outline_rounded, + label: l10n.comments, + onTap: onComments, + ), + ), + const SizedBox(width: 8), + Expanded( + child: SizedBox( + height: 32, + child: FilledButton( + key: const Key('player-next'), + onPressed: onNext, + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + disabledBackgroundColor: AppColors.raised, + disabledForegroundColor: AppColors.subtle, + foregroundColor: AppColors.onPrimary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(9), + ), + padding: EdgeInsets.zero, + textStyle: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Flexible( + child: Text( + l10n.nextEpisode, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 2), + const Icon(Icons.skip_next_rounded, size: 14), + ], + ), + ), + ), + ), + ], + ), + const SizedBox(height: 10), + PlayerFeaturedComment(drama: drama, onViewAll: onComments), + ], + ), + ), + ], + ), + ); + } + + Widget _topBar(BuildContext context) { + return SizedBox( + height: 46, + child: Row( + children: [ + if (onBack != null) + _TopAction( + key: const Key('player-back'), + tooltip: l10n.backToSeries, + icon: Icons.chevron_left_rounded, + onPressed: onBack!, + ) + else + const SizedBox(width: 28), + const SizedBox(width: 8), + Expanded( + child: Text( + '${localizedDramaTitle(l10n, drama)} · ' + '${l10n.episodeLabel(episode.number)}', + key: const Key('player-title'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: const TextStyle( + fontFamily: 'Outfit', + color: AppColors.onSurface, + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), + ), + const SizedBox(width: 8), + _TopAction( + key: const Key('player-more'), + tooltip: l10n.more, + icon: Icons.more_horiz_rounded, + onPressed: () => _openMore(context), + ), + ], + ), + ); + } + + Future _openMore(BuildContext context) async { + await showModalBottomSheet( + context: context, + backgroundColor: AppColors.card, + showDragHandle: true, + builder: (sheetContext) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, children: [ - const Spacer(), - Padding( - padding: const EdgeInsets.fromLTRB(20, 0, 8, 0), - child: Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Expanded(child: _info()), - _rail(), - ], + ListTile( + key: const Key('player-more-download'), + leading: const Icon( + Icons.download_outlined, + color: AppColors.secondary, ), + title: Text(l10n.downloads), + onTap: () { + Navigator.pop(sheetContext); + onDownload(); + }, ), - const SizedBox(height: 4), - Padding( - padding: EdgeInsets.fromLTRB( - 16, - 0, - 16, - 20 + MediaQuery.paddingOf(context).bottom, + if (onPip != null) + ListTile( + key: const Key('player-more-pip'), + leading: const Icon( + Icons.picture_in_picture_alt_outlined, + color: AppColors.secondary, + ), + title: Text(l10n.pictureInPicture), + onTap: () { + Navigator.pop(sheetContext); + onPip!(); + }, ), - child: SeekProgressBar( - valueListenable: controller, - semanticsLabel: l10n.seekVideo, - onScrubStart: onScrubStart, - onSeek: onSeek, + ListTile( + key: const Key('player-more-like'), + leading: Icon( + liked ? Icons.favorite_rounded : Icons.favorite_border_rounded, + color: liked ? AppColors.blue : AppColors.muted, ), + title: Text(l10n.like), + onTap: () { + Navigator.pop(sheetContext); + onLike(); + }, + ), + ListTile( + key: const Key('player-more-favorite'), + leading: Icon( + saved ? Icons.star_rounded : Icons.star_border_rounded, + color: saved ? AppColors.blue : AppColors.muted, + ), + title: Text(l10n.favorite), + onTap: () { + Navigator.pop(sheetContext); + onSave(); + }, + ), + ListTile( + key: const Key('player-more-share'), + leading: const Icon(Icons.share_outlined, color: AppColors.muted), + title: Text(l10n.share), + onTap: () { + Navigator.pop(sheetContext); + onShare(); + }, ), - if (showBottomNav) _fallbackNav(), ], ), - if (onBack != null) - Positioned( - top: 4, - left: 8, - child: IconButton( - tooltip: l10n.backToSeries, - onPressed: onBack, - style: IconButton.styleFrom( - foregroundColor: Colors.white, - backgroundColor: const Color(0x59000000), - ), - icon: const Icon(Icons.arrow_back_rounded), - ), - ), - Positioned( - top: 4, - right: 8, - child: IconButton( - tooltip: l10n.clearScreen, - onPressed: onClear, - style: IconButton.styleFrom( - foregroundColor: Colors.white, - backgroundColor: const Color(0x59000000), - ), - icon: const Icon(Icons.fit_screen_outlined), - ), - ), - ], - ), - ); - - Widget _info() => Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - localizedDramaTitle(l10n, drama), - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: Colors.white, - fontSize: 22, - fontWeight: FontWeight.w800, - height: 1.15, - shadows: [Shadow(blurRadius: 12, color: Colors.black)], - ), ), - const SizedBox(height: 6), - Text( - '${l10n.episodeLabel(episode.number)} · ${l10n.nowPlaying}', - style: const TextStyle( - color: Colors.white, - fontSize: 13, - fontWeight: FontWeight.w600, - shadows: [Shadow(blurRadius: 8, color: Colors.black)], - ), - ), - ], - ); - - Widget _rail() => Column( - mainAxisSize: MainAxisSize.min, - children: [ - _RailAction( - key: const Key('rail-like'), - icon: liked ? Icons.favorite : Icons.favorite_border, - label: l10n.like, - selected: liked, - onTap: onLike, - ), - _RailAction( - key: const Key('rail-save'), - icon: saved ? Icons.bookmark : Icons.bookmark_border, - label: l10n.save, - selected: saved, - onTap: onSave, - ), - _RailAction( - key: const Key('rail-download'), - icon: Icons.download_outlined, - label: l10n.downloads, - onTap: onDownload, - ), - _RailAction( - icon: Icons.grid_view_outlined, - label: l10n.episodes, - onTap: onEpisodes, - ), - _RailAction( - key: const Key('rail-comments'), - icon: Icons.chat_bubble_outline, - label: l10n.comments, - onTap: onComments, - ), - _RailAction( - key: const Key('rail-share'), - icon: Icons.share_outlined, - label: l10n.share, - onTap: onShare, - ), - if (onPip != null) - IconButton( - tooltip: l10n.pictureInPicture, - onPressed: onPip, - padding: EdgeInsets.zero, - constraints: const BoxConstraints.tightFor( - width: _railTapSize, - height: _railTapSize, - ), - color: AppColors.cream, - icon: const Icon( - Icons.picture_in_picture_alt_outlined, - size: _railIconSize, - ), - ), - ], - ); - - Widget _fallbackNav() => AppBottomNav( - items: [ - AppBottomNavItem( - tabKey: const Key('player-nav-home'), - icon: Icons.play_circle_outline_rounded, - selectedIcon: Icons.play_circle_fill_rounded, - label: l10n.homeTab, - selected: true, - onTap: () {}, - ), - AppBottomNavItem( - tabKey: const Key('player-nav-discover'), - icon: Icons.explore_outlined, - selectedIcon: Icons.explore_rounded, - label: l10n.discoverTab, - onTap: onDiscover, - ), - AppBottomNavItem( - tabKey: const Key('player-nav-my'), - icon: Icons.video_library_outlined, - selectedIcon: Icons.video_library_rounded, - label: l10n.myTab, - onTap: onMy, - ), - ], - ); + ); + } } -const double _railIconSize = 28; -const double _railIconWeight = 300; -const double _railTapSize = 44; +class _TopAction extends StatelessWidget { + const _TopAction({ + super.key, + required this.tooltip, + required this.icon, + required this.onPressed, + }); -class _RailAction extends StatelessWidget { - const _RailAction({ + final String tooltip; + final IconData icon; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 28, + height: 28, + child: IconButton( + tooltip: tooltip, + onPressed: onPressed, + padding: EdgeInsets.zero, + style: IconButton.styleFrom( + backgroundColor: const Color(0xFF2C2D33), + foregroundColor: AppColors.onSurface, + ), + icon: Icon(icon, size: 15), + ), + ); + } +} + +class _PlayerAction extends StatelessWidget { + const _PlayerAction({ super.key, required this.icon, required this.label, required this.onTap, - this.selected = false, }); + final IconData icon; final String label; - final VoidCallback? onTap; - final bool selected; + final VoidCallback onTap; @override - Widget build(BuildContext context) => IconButton( - tooltip: label, - onPressed: onTap, - padding: EdgeInsets.zero, - constraints: const BoxConstraints.tightFor( - width: _railTapSize, - height: _railTapSize, - ), - color: selected ? AppColors.blueSoft : AppColors.cream, - icon: Icon(icon, size: _railIconSize, weight: _railIconWeight, fill: 0), - ); + Widget build(BuildContext context) { + return Material( + color: const Color(0xFF2A2A30), + borderRadius: BorderRadius.circular(9), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: SizedBox( + height: 32, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: 14, color: AppColors.secondary), + const SizedBox(width: 3), + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 11, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + ), + ); + } } diff --git a/lib/pages/player/widgets/home_feed_overlay.dart b/lib/pages/player/widgets/home_feed_overlay.dart index e631877..1e0de71 100644 --- a/lib/pages/player/widgets/home_feed_overlay.dart +++ b/lib/pages/player/widgets/home_feed_overlay.dart @@ -3,27 +3,21 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:video_player/video_player.dart'; import '../../../core/data/models/drama.dart'; import '../../../core/formatting/localized_content.dart'; import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_dimens.dart'; import '../../../core/widgets/glass_panel.dart'; -import '../../../core/widgets/remote_image.dart'; import '../../../l10n/app_localizations.dart'; -import 'seek_progress_bar.dart'; -/// Immersive home layer from `screens/01-home.html`. -/// -/// Playback, ticket, progress, favorite, like, comment, and share callbacks are -/// all owned by [PlayerPage]; this widget only paints the design overlay. +/// Home feed layer from `docs/移动端设计预览.html` screen 01. class HomeFeedOverlay extends StatelessWidget { const HomeFeedOverlay({ super.key, required this.l10n, required this.drama, required this.episode, - required this.controller, required this.liked, required this.saved, required this.onLike, @@ -34,15 +28,12 @@ class HomeFeedOverlay extends StatelessWidget { required this.onDownload, required this.onClear, required this.onSearch, - required this.onScrubStart, - required this.onSeek, this.onPip, }); final AppLocalizations l10n; final Drama drama; final Episode episode; - final VideoPlayerController? controller; final bool liked; final bool saved; final VoidCallback onLike; @@ -54,12 +45,9 @@ class HomeFeedOverlay extends StatelessWidget { final VoidCallback onClear; final VoidCallback onSearch; final VoidCallback? onPip; - final VoidCallback? onScrubStart; - final Future Function(Duration)? onSeek; @override Widget build(BuildContext context) { - final compact = MediaQuery.sizeOf(context).height < 620; return SafeArea( bottom: false, child: Column( @@ -67,16 +55,16 @@ class HomeFeedOverlay extends StatelessWidget { _header(context), Expanded( child: Align( - alignment: Alignment.bottomRight, + alignment: Alignment.centerRight, child: Padding( - padding: const EdgeInsets.fromLTRB(0, 0, 12, 4), - child: _rail(compact), + padding: const EdgeInsets.fromLTRB(0, 0, 10, 12), + child: _rail(), ), ), ), Padding( - padding: const EdgeInsets.fromLTRB(12, 0, 12, 10), - child: _infoCard(context, compact), + padding: const EdgeInsets.fromLTRB(14, 0, 14, 14), + child: _infoCard(), ), ], ), @@ -85,79 +73,35 @@ class HomeFeedOverlay extends StatelessWidget { Widget _header(BuildContext context) { return Padding( - padding: const EdgeInsets.fromLTRB(16, 6, 12, 4), + padding: const EdgeInsets.fromLTRB(14, 6, 10, 4), child: Row( - crossAxisAlignment: CrossAxisAlignment.center, children: [ Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Container( - width: 8, - height: 8, - decoration: const BoxDecoration( - color: AppColors.blue, - shape: BoxShape.circle, - ), - ), - const SizedBox(width: 7), - Flexible( - child: Text( - localizedDramaTitle(l10n, drama), - key: const Key('home-feed-title'), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontFamily: 'Outfit', - color: AppColors.onSurface, - fontSize: 17, - fontWeight: FontWeight.w700, - height: 1.1, - shadows: [ - Shadow(blurRadius: 10, color: Colors.black87), - ], - ), - ), - ), - if (drama.isHot) ...[const SizedBox(width: 6), _hotBadge()], - ], - ), - const SizedBox(height: 3), - Text( - '${l10n.seasonOneVerticalCinema} · ${l10n.nowPlaying}', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: Colors.white.withValues(alpha: 0.64), - fontSize: 10.5, - fontWeight: FontWeight.w500, - shadows: const [ - Shadow(blurRadius: 8, color: Colors.black87), - ], - ), - ), - ], + child: Text( + localizedDramaTitle(l10n, drama), + key: const Key('home-feed-title'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontFamily: 'Outfit', + color: AppColors.onSurface, + fontSize: 13, + fontWeight: FontWeight.w800, + shadows: [Shadow(blurRadius: 8, color: Colors.black)], + ), ), ), - const SizedBox(width: 6), - _GlassIconButton( + _RoundIcon( + key: const Key('home-feed-search'), tooltip: l10n.search, icon: Icons.search_rounded, onPressed: onSearch, ), - const SizedBox(width: 4), - _GlassIconButton( - tooltip: l10n.clearScreen, - icon: Icons.fit_screen_outlined, - onPressed: onClear, - ), - const SizedBox(width: 4), - _GlassIconButton( + const SizedBox(width: 6), + _RoundIcon( + key: const Key('home-feed-more'), tooltip: l10n.more, - icon: Icons.more_horiz_rounded, + icon: Icons.menu_rounded, onPressed: () => _openMore(context), ), ], @@ -165,131 +109,53 @@ class HomeFeedOverlay extends StatelessWidget { ); } - Widget _hotBadge() { - return DecoratedBox( - decoration: BoxDecoration( - color: AppColors.blue.withValues(alpha: 0.18), - borderRadius: BorderRadius.circular(5), - border: Border.all(color: AppColors.blue.withValues(alpha: 0.4)), - ), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1), - child: Text( - l10n.hot, - style: const TextStyle( - color: AppColors.blue, - fontSize: 8, - fontWeight: FontWeight.w800, - letterSpacing: 0.4, - height: 1.1, - ), - ), - ), - ); - } - - Widget _rail(bool compact) { + Widget _rail() { return Column( mainAxisSize: MainAxisSize.min, children: [ - Semantics( - button: true, - label: saved ? l10n.followingTab : l10n.followSeries, - child: InkWell( - key: const Key('home-feed-follow'), - onTap: onSave, - customBorder: const CircleBorder(), - child: Stack( - clipBehavior: Clip.none, - children: [ - Container( - width: compact ? 40 : 44, - height: compact ? 40 : 44, - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all(color: AppColors.blue, width: 2), - color: AppColors.raised, - ), - child: ClipOval( - child: RemoteImage( - drama.posterUrl, - fit: BoxFit.cover, - showFallbackIcon: true, - ), - ), - ), - Positioned( - bottom: -4, - left: 14, - child: Container( - width: 16, - height: 16, - decoration: BoxDecoration( - color: saved ? AppColors.success : AppColors.blue, - shape: BoxShape.circle, - border: Border.all(color: AppColors.ink, width: 2), - ), - child: Icon( - saved ? Icons.check_rounded : Icons.add_rounded, - color: AppColors.onPrimary, - size: 11, - ), - ), - ), - ], - ), - ), - ), - const SizedBox(height: 8), - _RailAction( - compact: compact, + _RailItem( key: const Key('rail-like'), icon: liked ? Icons.favorite_rounded : Icons.favorite_border_rounded, label: l10n.like, selected: liked, onTap: onLike, ), - _RailAction( - compact: compact, - key: const Key('rail-save'), - icon: saved ? Icons.bookmark_rounded : Icons.bookmark_border_rounded, - label: l10n.save, - selected: saved, - onTap: onSave, - ), - _RailAction( - compact: compact, + _RailItem( key: const Key('rail-comments'), icon: Icons.chat_bubble_outline_rounded, label: l10n.comments, onTap: onComments, ), - _RailAction( - compact: compact, + _RailItem( key: const Key('rail-share'), - icon: Icons.share_outlined, + icon: Icons.ios_share_rounded, label: l10n.share, onTap: onShare, ), + _RailItem( + key: const Key('rail-save'), + icon: saved ? Icons.star_rounded : Icons.star_border_rounded, + label: l10n.favorite, + selected: saved, + onTap: onSave, + ), ], ); } - Widget _infoCard(BuildContext context, bool compact) { - final genres = [ - if (drama.genre.isNotEmpty) drama.genre, - if (drama.category.isNotEmpty) drama.category, - ].take(2).toList(); + Widget _infoCard() { + final genre = drama.genre.isEmpty ? drama.category : drama.genre; return GlassPanel( key: const Key('home-feed-info-card'), - radius: 26, - blur: 24, - padding: const EdgeInsets.fromLTRB(16, 13, 16, 12), + radius: 14, + blur: 6, + padding: const EdgeInsets.fromLTRB(12, 11, 12, 11), gradient: const LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, - colors: [Color(0x260B0C10), Color(0x590B0C10)], + colors: [Color(0xBF0F0F14), Color(0xA60F0F14)], ), + borderColor: Colors.white.withValues(alpha: 0.06), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, @@ -298,94 +164,73 @@ class HomeFeedOverlay extends StatelessWidget { children: [ Expanded( child: Wrap( - spacing: 5, + spacing: 6, runSpacing: 4, children: [ - for (var i = 0; i < genres.length; i++) - _genreChip(genres[i], i.isEven), + if (genre.isNotEmpty) + _tag( + genre, + background: AppColors.blue, + foreground: AppColors.onPrimary, + ), + if (drama.category.isNotEmpty && drama.category != genre) + _tag( + drama.category, + background: const Color(0xFF333333), + foreground: const Color(0xFFDDDDDD), + ), ], ), ), const SizedBox(width: 6), - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.06), - borderRadius: BorderRadius.circular(999), - border: Border.all(color: AppColors.border), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox( - width: 5, - height: 5, - child: DecoratedBox( - decoration: BoxDecoration( - color: AppColors.success, - shape: BoxShape.circle, - ), - ), - ), - const SizedBox(width: 4), - Text( - '${l10n.episodeLabel(episode.number)} / ' - '${drama.episodeCount}', - key: const Key('home-feed-episode'), - style: const TextStyle( - color: AppColors.cream, - fontSize: 10, - fontWeight: FontWeight.w600, - ), - ), - ], + Flexible( + child: Text( + l10n.episodeOfTotal(episode.number, drama.episodeCount), + key: const Key('home-feed-episode'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.right, + style: const TextStyle(color: Color(0xFFBBBBBB), fontSize: 9), ), ), ], ), - const SizedBox(height: 8), + const SizedBox(height: 7), Text( - drama.synopsis, - maxLines: compact ? 1 : 2, + localizedEpisodeTitle(l10n, episode.number), + key: const Key('home-feed-episode-title'), + maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( - color: AppColors.cream, - fontSize: 12, - height: 1.35, + color: AppColors.onSurface, + fontSize: 13, + fontWeight: FontWeight.w800, ), ), - const SizedBox(height: 6), - SeekProgressBar( - valueListenable: controller, - semanticsLabel: l10n.seekVideo, - showTimes: false, - compact: true, - onScrubStart: onScrubStart, - onSeek: onSeek, - ), - _timeRow(), - const SizedBox(height: 8), + const SizedBox(height: 10), Row( children: [ Expanded( child: SizedBox( - height: 40, + height: 34, child: FilledButton.icon( key: const Key('home-feed-follow-series'), onPressed: onSave, style: FilledButton.styleFrom( backgroundColor: AppColors.blue, foregroundColor: AppColors.onPrimary, - shape: const StadiumBorder(), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + AppDimens.buttonRadius, + ), + ), + padding: EdgeInsets.zero, textStyle: const TextStyle( fontSize: 12, fontWeight: FontWeight.w700, ), ), - icon: Icon( - saved ? Icons.check_rounded : Icons.add_rounded, - size: 16, - ), + icon: const Icon(Icons.play_arrow_rounded, size: 16), label: Text( saved ? l10n.followingTab : l10n.followSeries, maxLines: 1, @@ -395,11 +240,33 @@ class HomeFeedOverlay extends StatelessWidget { ), ), const SizedBox(width: 8), - _GlassAction( - tooltip: l10n.episodes, - icon: Icons.grid_view_rounded, - label: l10n.episodes, - onPressed: onEpisodes, + Expanded( + child: SizedBox( + height: 34, + child: FilledButton( + key: const Key('home-feed-episodes'), + onPressed: onEpisodes, + style: FilledButton.styleFrom( + backgroundColor: const Color(0xFF2A2A30), + foregroundColor: AppColors.onSurface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + AppDimens.buttonRadius, + ), + ), + padding: EdgeInsets.zero, + textStyle: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + child: Text( + l10n.episodes, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ), ), ], ), @@ -408,41 +275,29 @@ class HomeFeedOverlay extends StatelessWidget { ); } - Widget _genreChip(String label, bool secondary) { - final color = secondary ? AppColors.secondary : AppColors.blue; + Widget _tag( + String label, { + required Color background, + required Color foreground, + }) { return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( - color: color.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(999), - border: Border.all(color: color.withValues(alpha: 0.3)), + color: background, + borderRadius: BorderRadius.circular(6), ), child: Text( label, maxLines: 1, - overflow: TextOverflow.ellipsis, style: TextStyle( - color: color, - fontSize: 10, - fontWeight: FontWeight.w600, - height: 1.1, + color: foreground, + fontSize: 8, + fontWeight: FontWeight.w800, ), ), ); } - Widget _timeRow() { - final source = controller; - if (source == null) { - return const _Times(position: Duration.zero, duration: Duration.zero); - } - return ValueListenableBuilder( - valueListenable: source, - builder: (context, value, _) => - _Times(position: value.position, duration: value.duration), - ); - } - Future _openMore(BuildContext context) async { await showModalBottomSheet( context: context, @@ -456,7 +311,7 @@ class HomeFeedOverlay extends StatelessWidget { key: const Key('home-feed-download'), leading: const Icon( Icons.download_outlined, - color: AppColors.blueSoft, + color: AppColors.secondary, ), title: Text(l10n.downloads), onTap: () { @@ -469,7 +324,7 @@ class HomeFeedOverlay extends StatelessWidget { key: const Key('home-feed-pip'), leading: const Icon( Icons.picture_in_picture_alt_outlined, - color: AppColors.blueSoft, + color: AppColors.secondary, ), title: Text(l10n.pictureInPicture), onTap: () { @@ -478,9 +333,10 @@ class HomeFeedOverlay extends StatelessWidget { }, ), ListTile( + key: const Key('home-feed-clear'), leading: const Icon( Icons.fit_screen_outlined, - color: AppColors.blueSoft, + color: AppColors.secondary, ), title: Text(l10n.clearScreen), onTap: () { @@ -495,35 +351,9 @@ class HomeFeedOverlay extends StatelessWidget { } } -class _Times extends StatelessWidget { - const _Times({required this.position, required this.duration}); - - final Duration position; - final Duration duration; - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 2), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - _format(position), - style: const TextStyle(color: AppColors.muted, fontSize: 9), - ), - Text( - _format(duration), - style: const TextStyle(color: AppColors.muted, fontSize: 9), - ), - ], - ), - ); - } -} - -class _GlassIconButton extends StatelessWidget { - const _GlassIconButton({ +class _RoundIcon extends StatelessWidget { + const _RoundIcon({ + super.key, required this.tooltip, required this.icon, required this.onPressed, @@ -531,140 +361,76 @@ class _GlassIconButton extends StatelessWidget { final String tooltip; final IconData icon; - final VoidCallback? onPressed; + final VoidCallback onPressed; @override Widget build(BuildContext context) { return SizedBox( - width: 34, - height: 34, + width: 28, + height: 28, child: IconButton( tooltip: tooltip, onPressed: onPressed, padding: EdgeInsets.zero, style: IconButton.styleFrom( - backgroundColor: Colors.black.withValues(alpha: 0.28), + backgroundColor: const Color(0xCC2C2D33), foregroundColor: AppColors.onSurface, - side: BorderSide(color: Colors.white.withValues(alpha: 0.16)), ), - icon: Icon(icon, size: 18), + icon: Icon(icon, size: 14), ), ); } } -class _GlassAction extends StatelessWidget { - const _GlassAction({ - required this.tooltip, - required this.icon, - required this.label, - required this.onPressed, - }); - - final String tooltip; - final IconData icon; - final String label; - final VoidCallback onPressed; - - @override - Widget build(BuildContext context) { - return Tooltip( - message: tooltip, - child: InkWell( - onTap: onPressed, - borderRadius: BorderRadius.circular(999), - child: Container( - height: 40, - padding: const EdgeInsets.symmetric(horizontal: 12), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.10), - borderRadius: BorderRadius.circular(999), - border: Border.all(color: Colors.white.withValues(alpha: 0.15)), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, color: AppColors.secondary, size: 15), - const SizedBox(width: 5), - Text( - label, - maxLines: 1, - style: const TextStyle( - color: AppColors.cream, - fontSize: 11, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - ), - ); - } -} - -class _RailAction extends StatelessWidget { - const _RailAction({ +class _RailItem extends StatelessWidget { + const _RailItem({ super.key, required this.icon, required this.label, required this.onTap, this.selected = false, - this.compact = false, }); final IconData icon; final String label; final VoidCallback onTap; final bool selected; - final bool compact; @override Widget build(BuildContext context) { return Padding( - padding: EdgeInsets.only(bottom: compact ? 2 : 3), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - GlassPanel( - radius: 999, - blur: 16, - child: SizedBox( - width: compact ? 40 : 38, - height: compact ? 40 : 38, - child: IconButton( - tooltip: label, - onPressed: onTap, - padding: EdgeInsets.zero, - icon: Icon( + padding: const EdgeInsets.only(bottom: 14), + child: Semantics( + button: true, + label: label, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(22), + child: SizedBox( + width: 44, + child: Column( + children: [ + Icon( icon, - size: 19, + size: 22, color: selected ? AppColors.blue : AppColors.onSurface, ), - ), + const SizedBox(height: 2), + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.cream, + fontSize: 9, + shadows: [Shadow(blurRadius: 6, color: Colors.black)], + ), + ), + ], ), ), - if (!compact) ...[ - const SizedBox(height: 2), - Text( - label, - maxLines: 1, - style: const TextStyle( - color: AppColors.cream, - fontSize: 9, - fontWeight: FontWeight.w600, - shadows: [Shadow(blurRadius: 6, color: Colors.black)], - ), - ), - ], - ], + ), ), ); } } - -String _format(Duration value) { - final minutes = value.inMinutes.remainder(60).toString().padLeft(2, '0'); - final seconds = value.inSeconds.remainder(60).toString().padLeft(2, '0'); - return '$minutes:$seconds'; -} diff --git a/lib/pages/player/widgets/player_featured_comment.dart b/lib/pages/player/widgets/player_featured_comment.dart new file mode 100644 index 0000000..e888ba8 --- /dev/null +++ b/lib/pages/player/widgets/player_featured_comment.dart @@ -0,0 +1,158 @@ +// Author: fruit.com.cn + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../../core/data/models/comment.dart'; +import '../../../core/data/models/drama.dart'; +import '../../../core/data/repositories/comment_repository.dart'; +import '../../../core/data/repositories/interaction_repository.dart'; +import '../../../core/data/services/comment_controller.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../l10n/app_localizations.dart'; + +class PlayerFeaturedComment extends StatefulWidget { + const PlayerFeaturedComment({ + super.key, + required this.drama, + required this.onViewAll, + }); + + final Drama drama; + final VoidCallback onViewAll; + + @override + State createState() => _PlayerFeaturedCommentState(); +} + +class _PlayerFeaturedCommentState extends State { + late final CommentController _controller; + + @override + void initState() { + super.initState(); + _controller = CommentController( + comments: context.read(), + interactions: context.read(), + dramaId: widget.drama.id, + ); + unawaited(_controller.load()); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: _controller, + builder: (context, _) { + if (_controller.comments.isEmpty) return const SizedBox.shrink(); + return _card(context, _controller.comments.first); + }, + ); + } + + Widget _card(BuildContext context, DramaComment comment) { + final l10n = AppLocalizations.of(context)!; + final time = _time(l10n, comment.createdAt); + return InkWell( + key: const Key('player-featured-comment'), + onTap: widget.onViewAll, + borderRadius: BorderRadius.circular(10), + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(9), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(10), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 18, + height: 18, + alignment: Alignment.center, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: AppColors.secondary.withValues(alpha: 0.2), + ), + child: Text( + _initial(comment.nickname), + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 8, + fontWeight: FontWeight.w700, + ), + ), + ), + const SizedBox(width: 6), + Expanded( + child: Row( + children: [ + Flexible( + child: Text( + comment.nickname.isEmpty + ? l10n.localViewer + : comment.nickname, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 10, + fontWeight: FontWeight.w700, + ), + ), + ), + if (time != null) ...[ + const SizedBox(width: 4), + Text( + '· $time', + style: const TextStyle( + color: Color(0xFF777777), + fontSize: 9, + ), + ), + ], + ], + ), + ), + ], + ), + const SizedBox(height: 5), + Text( + comment.content, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Color(0xFFDDDDDD), + fontSize: 10, + height: 1.5, + ), + ), + ], + ), + ), + ); + } + + String? _time(AppLocalizations l10n, DateTime? createdAt) { + if (createdAt == null) return null; + final diff = DateTime.now().difference(createdAt); + if (diff.inMinutes < 1) return null; + return l10n.minutesAgo(diff.inMinutes); + } + + String _initial(String nickname) { + final trimmed = nickname.trim(); + return trimmed.isEmpty ? '?' : trimmed.substring(0, 1).toUpperCase(); + } +} diff --git a/lib/pages/player/widgets/seek_progress_bar.dart b/lib/pages/player/widgets/seek_progress_bar.dart index cc2855d..6680fa1 100644 --- a/lib/pages/player/widgets/seek_progress_bar.dart +++ b/lib/pages/player/widgets/seek_progress_bar.dart @@ -129,7 +129,7 @@ class _SeekProgressBarState extends State { activeTrackColor: AppColors.blue, inactiveTrackColor: widget.compact ? Colors.white.withValues(alpha: 0.18) - : Colors.white30, + : const Color(0xFF474248), thumbColor: widget.compact ? AppColors.blue : Colors.white, thumbShape: RoundSliderThumbShape( enabledThumbRadius: widget.compact diff --git a/test/discover/home_all_dramas_grid_test.dart b/test/discover/home_all_dramas_grid_test.dart index b19c94d..3ff02ef 100644 --- a/test/discover/home_all_dramas_grid_test.dart +++ b/test/discover/home_all_dramas_grid_test.dart @@ -39,44 +39,42 @@ void main() { expect(find.text('Continue Watching'), findsNothing); expect(find.byKey(const Key('home-all-dramas-title')), findsNothing); - expect(find.text('All'), findsOneWidget); + expect(find.text('Recommended'), findsOneWidget); expect(find.text('For You'), findsNothing); - expect( - tester.getCenter(find.text('All')).dx, - lessThan(tester.getCenter(find.text('Hot')).dx), - ); final grid = tester.widget( find.byKey(const Key('home-all-dramas-grid')), ); final delegate = grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount; expect(delegate.crossAxisCount, 2); - expect(delegate.crossAxisSpacing, 12); - expect(delegate.mainAxisSpacing, 14); - expect(delegate.childAspectRatio, closeTo(0.55, 0.001)); + expect(delegate.crossAxisSpacing, 10); + expect(delegate.mainAxisSpacing, 10); + expect(delegate.mainAxisExtent, 164); expect( tester .widget( find.byKey(const Key('home-all-dramas-section')), ) .padding, - const EdgeInsets.symmetric(horizontal: 16), + const EdgeInsets.symmetric(horizontal: 14), ); expect(find.byType(GridView), findsNothing); expect(find.byType(CustomScrollView), findsOneWidget); expect(find.byType(RemoteImage), findsWidgets); - final cover = tester.widget( - find.byKey(const Key('home-drama-cover-city-lights-demo')), + expect( + tester + .getSize(find.byKey(const Key('home-drama-cover-city-lights-demo'))) + .height, + 120, ); - expect(cover.aspectRatio, closeTo(3 / 4.2, 0.001)); expect( tester .widget( find.byKey(const Key('home-drama-title-city-lights-demo')), ) .maxLines, - 2, + 1, ); expect( tester @@ -100,14 +98,14 @@ void main() { await tester.tap(find.byKey(const Key('tab-discover'))); await tester.pumpAndSettle(); - expect(find.text('The Signal'), findsOneWidget); - expect(find.text('City Lights & Velvet Nights'), findsOneWidget); + expect(find.text('The Signal'), findsWidgets); + expect(find.text('City Lights & Velvet Nights'), findsWidgets); await tester.tap(find.text('Romance')); await tester.pumpAndSettle(); expect(find.text('The Signal'), findsNothing); - expect(find.text('City Lights & Velvet Nights'), findsOneWidget); + expect(find.text('City Lights & Velvet Nights'), findsWidgets); expect( find.byKey(const Key('home-drama-card-the-signal-demo')), findsNothing, @@ -182,7 +180,7 @@ void main() { await tester.pumpAndSettle(); expect(find.byKey(const Key('home-all-dramas-title')), findsNothing); - expect(find.text('City Lights & Velvet Nights'), findsOneWidget); + expect(find.text('City Lights & Velvet Nights'), findsWidgets); expect(tester.takeException(), isNull); }); diff --git a/test/drama_detail/drama_detail_view_test.dart b/test/drama_detail/drama_detail_view_test.dart index 3bd89f3..7fee3c7 100644 --- a/test/drama_detail/drama_detail_view_test.dart +++ b/test/drama_detail/drama_detail_view_test.dart @@ -21,7 +21,7 @@ import '../support/fake_haptics.dart'; import '../support/fake_interaction_repositories.dart'; void main() { - testWidgets('should_render_hero_cta_synopsis_and_two_column_episode_grid', ( + testWidgets('should_render_preview_hero_cta_synopsis_and_episode_chips', ( tester, ) async { tester.view.physicalSize = const Size(390, 844); @@ -32,45 +32,24 @@ void main() { await tester.pumpWidget(_host(_detailDrama())); await tester.pumpAndSettle(); - final hero = tester.widget( - find.byKey(const Key('detail-hero')), - ); - expect(hero.aspectRatio, closeTo(16 / 9, 0.001)); + expect(tester.getSize(find.byKey(const Key('detail-hero'))).height, 190); expect(find.byKey(const Key('detail-cta')), findsOneWidget); - expect(find.byKey(const Key('detail-favorite')), findsOneWidget); - expect(find.byKey(const Key('detail-download')), findsOneWidget); - expect(find.byKey(const Key('detail-episodes-range')), findsOneWidget); + expect(tester.getSize(find.byKey(const Key('detail-cta'))).height, 40); expect(find.byKey(const Key('detail-episode-chip-1')), findsOneWidget); - expect( - tester.getSize(find.byKey(const Key('detail-episode-chip-1'))), - const Size(46, 44), - ); + expect(find.byKey(const Key('detail-episode-chip-8')), findsOneWidget); final synopsis = tester.widget( find.byKey(const Key('detail-synopsis')), ); - expect(synopsis.maxLines, 2); - await tester.tap(find.byKey(const Key('detail-synopsis-toggle'))); - await tester.pumpAndSettle(); - expect( - tester.widget(find.byKey(const Key('detail-synopsis'))).maxLines, - 100, - ); + expect(synopsis.style?.fontSize, 11); + expect(synopsis.style?.height, 1.6); - final grid = tester.widget( - find.descendant( - of: find.byKey(const Key('detail-episode-grid')), - matching: find.byType(SliverGrid), - ), - ); - final delegate = - grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount; - expect(delegate.crossAxisCount, 2); - expect(find.byKey(const Key('detail-episode-card-1')), findsOneWidget); - expect( - tester.getSize(find.byKey(const Key('detail-episode-thumb-1'))), - const Size(96, 64), - ); + await tester.tap(find.byKey(const Key('detail-more'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('detail-favorite')), findsOneWidget); + expect(find.byKey(const Key('detail-download')), findsOneWidget); + expect(find.byKey(const Key('detail-share')), findsOneWidget); + expect(find.byKey(const Key('detail-report')), findsOneWidget); expect(tester.takeException(), isNull); }); @@ -90,7 +69,7 @@ void main() { ); await tester.pumpAndSettle(); - expect(find.byKey(const Key('detail-episode-card-3')), findsOneWidget); + expect(find.byKey(const Key('detail-episode-chip-8')), findsOneWidget); expect(tester.takeException(), isNull); }); } diff --git a/test/home/home_preview_layout_test.dart b/test/home/home_preview_layout_test.dart new file mode 100644 index 0000000..ca085e3 --- /dev/null +++ b/test/home/home_preview_layout_test.dart @@ -0,0 +1,64 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.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 'package:fruitshorts/app.dart'; +import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; +import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; +import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; + +import '../support/fake_catalog.dart'; +import '../support/fake_category.dart'; +import '../support/fake_haptics.dart'; +import '../support/fake_interaction_repositories.dart'; +import '../support/fake_notice_repository.dart'; +import '../support/widget_player.dart'; + +void main() { + testWidgets('should_render_preview_home_card_without_hot_or_follow_avatar', ( + tester, + ) async { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final catalog = FakeCatalogRepository(); + await tester.pumpWidget( + LumaDramaApp( + playerFactory: createWidgetPlayer, + catalogRepository: catalog, + categoryRepository: FakeCategoryRepository(catalog: catalog), + watchHistoryRepository: InMemoryWatchHistoryRepository(), + favoriteRepository: InMemoryFavoriteRepository(cards: catalog.dramas), + playbackRepository: FakePlaybackRepository(), + commentRepository: FakeCommentRepository(), + interactionRepository: FakeInteractionRepository(), + noticeRepository: FakeNoticeRepository(), + haptics: FakeHaptics(), + ), + ); + await tester.pumpAndSettle(); + 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); + expect(find.byKey(const Key('home-feed-follow-series')), findsOneWidget); + expect(find.byKey(const Key('home-feed-episodes')), findsOneWidget); + expect( + tester.getSize(find.byKey(const Key('home-feed-follow-series'))).height, + 34, + ); + expect(find.text('HOT'), findsNothing); + expect(find.textContaining('Season'), findsNothing); + expect(find.byKey(const Key('home-feed-follow')), findsNothing); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 1b4468a..e9693bc 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -29,11 +29,15 @@ void main() { await _pumpApp(tester); await tester.tap(find.byKey(const Key('tab-discover'))); await tester.pumpAndSettle(); - await tester.tap(find.text('City Lights & Velvet Nights')); + await tester.tap(find.byKey(const Key('home-drama-card-city-lights-demo'))); await tester.pumpAndSettle(); _expectFullscreenOverlay(); expect(find.byType(PageView), findsOneWidget); + await tester.tap(find.byKey(const Key('player-more'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('player-more-download')), findsOneWidget); + expect(find.byKey(const Key('player-more-pip')), findsOneWidget); expect(tester.takeException(), isNull); }); @@ -57,24 +61,29 @@ void main() { await tester.tap(find.byKey(const Key('detail-episode-chip-2'))); await tester.pumpAndSettle(); _expectFullscreenOverlay(); - expect(find.text('Episode 2 · Playing now'), findsOneWidget); + expect( + tester.widget(find.byKey(const Key('player-title'))).data, + contains('Episode 2'), + ); expect(tester.takeException(), isNull); }); } void _expectFullscreenOverlay() { - expect(find.byKey(const Key('rail-like')), findsOneWidget); - expect(find.byKey(const Key('rail-save')), findsOneWidget); - expect(find.byKey(const Key('rail-comments')), findsOneWidget); - expect(find.byKey(const Key('rail-share')), findsOneWidget); - expect(find.byTooltip('Picture in picture'), findsOneWidget); - expect(find.byTooltip('Clear screen'), findsOneWidget); + expect(find.byKey(const Key('player-back')), findsOneWidget); + expect(find.byKey(const Key('player-title')), findsOneWidget); + expect(find.byKey(const Key('player-more')), findsOneWidget); + expect(find.byKey(const Key('player-play-toggle')), findsOneWidget); + expect(find.byKey(const Key('player-episodes')), findsOneWidget); + expect(find.byKey(const Key('player-comments')), findsOneWidget); + expect(find.byKey(const Key('player-next')), findsOneWidget); expect(find.byType(Slider), findsOneWidget); + expect(find.byKey(const Key('rail-like')), findsNothing); + expect(find.byKey(const Key('rail-save')), findsNothing); expect(find.byKey(const Key('card-player-video-frame')), findsNothing); expect(find.byKey(const Key('card-player-progress')), findsNothing); expect(find.byKey(const Key('card-player-next')), findsNothing); - expect(find.byKey(const Key('player-title')), findsNothing); } Future _pumpApp(WidgetTester tester) async { diff --git a/test/widget_test.dart b/test/widget_test.dart index 0d90f10..10525fd 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -110,17 +110,19 @@ void main() { await tester.tap(find.byKey(const Key('tab-home'))); await tester.pumpAndSettle(); expect(find.text('The Signal'), findsOneWidget); - expect(find.byType(Slider), findsOneWidget); + expect(find.byType(Slider), findsNothing); expect(find.text('Home'), findsWidgets); - expect(find.textContaining('Playing now'), findsOneWidget); - expect(find.text('Recommended'), findsNothing); - expect(find.text('Following'), findsNothing); - expect(find.byTooltip('Episodes'), findsWidgets); - expect(find.byTooltip('Clear screen'), findsOneWidget); - expect(find.byTooltip('Picture in picture'), findsNothing); - await tester.tap(find.byTooltip('More')); + 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.text('Picture in picture'), findsOneWidget); + expect(find.byKey(const Key('home-feed-download')), findsOneWidget); + expect(find.byKey(const Key('home-feed-pip')), findsOneWidget); + expect(find.byKey(const Key('home-feed-clear')), findsOneWidget); await tester.binding.handlePopRoute(); await tester.pumpAndSettle(); expect(find.text('Watch Full'), findsNothing); @@ -139,14 +141,16 @@ void main() { await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('tab-home'))); await tester.pumpAndSettle(); - await tester.tap(find.byTooltip('Clear screen')); + await tester.tap(find.byKey(const Key('home-feed-more'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('home-feed-clear'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('home-tabs-bar')), findsNothing); expect(find.byTooltip('Exit clear screen'), findsOneWidget); await tester.tap(find.byTooltip('Exit clear screen')); await tester.pumpAndSettle(); expect(find.byKey(const Key('home-tabs-bar')), findsOneWidget); - expect(find.byTooltip('Clear screen'), findsOneWidget); + expect(find.byKey(const Key('home-feed-more')), findsOneWidget); expect(tester.takeException(), isNull); }); @@ -233,12 +237,11 @@ void main() { expect(find.text('我的'), findsWidgets); await tester.drag(find.byType(ListView), const Offset(0, 700)); await tester.pumpAndSettle(); - expect(find.text('我的账户'), findsOneWidget); + expect(find.byKey(const Key('mine-wallet')), findsOneWidget); expect(find.text('首页'), findsOneWidget); - await tester.tap(find.text('找剧')); + await tester.tap(find.text('发现')); await tester.pumpAndSettle(); - expect(find.text('全部'), findsOneWidget); - expect(find.text('热门'), findsOneWidget); + expect(find.text('推荐'), findsOneWidget); }); testWidgets( @@ -259,7 +262,9 @@ void main() { expect(find.text('City Lights & Velvet Nights'), findsOneWidget); await tester.tap(find.byKey(const Key('tab-discover'))); await tester.pumpAndSettle(); - await tester.tap(find.text('City Lights & Velvet Nights')); + 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); @@ -324,7 +329,7 @@ void main() { await tester.tap(find.byKey(const Key('tab-discover'))); await tester.pumpAndSettle(); - expect(find.text('All'), findsOneWidget); + expect(find.text('Recommended'), findsOneWidget); expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); expect(find.byKey(const Key('discover-profile')), findsOneWidget); expect( @@ -341,7 +346,7 @@ void main() { ), findsOneWidget, ); - expect(find.byTooltip('Search'), findsNothing); + expect(find.byTooltip('Search'), findsOneWidget); expect(tester.takeException(), isNull); await tester.tap(find.byKey(const Key('tab-my'))); @@ -372,7 +377,7 @@ void main() { 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'), findsNothing); + expect(find.byTooltip('Search'), findsOneWidget); expect( Navigator.of( tester.element(find.byKey(const Key('home-all-dramas-grid'))), @@ -396,7 +401,7 @@ void main() { 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'), findsNothing); + expect(find.byTooltip('Search'), findsOneWidget); expect( Navigator.of( tester.element(find.byKey(const Key('home-all-dramas-grid'))), @@ -440,12 +445,17 @@ void main() { await tester.tap(find.byKey(const Key('tab-discover'))); await tester.pumpAndSettle(); - await tester.tap(find.text('City Lights & Velvet Nights')); + 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(find.text('Episode 2 · Playing now'), findsOneWidget); + expect( + tester.widget(find.byKey(const Key('player-title'))).data, + contains('Episode 2'), + ); expect(find.textContaining('City Lights & Velvet Nights'), findsWidgets); }, ); @@ -507,9 +517,9 @@ void main() { await tester.pumpAndSettle(); final keys = [ const Key('rail-like'), - const Key('rail-save'), const Key('rail-comments'), const Key('rail-share'), + const Key('rail-save'), ]; final tops = keys .map((key) => tester.getTopLeft(find.byKey(key)).dy) @@ -644,10 +654,8 @@ void main() { await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('tab-my'))); await tester.pumpAndSettle(); - await tester.scrollUntilVisible( - find.byKey(const Key('mine-library-tab-1')), - 300, - ); + await tester.drag(find.byType(ListView), const Offset(0, -320)); + await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('mine-library-tab-1'))); await tester.pumpAndSettle(); expect(find.text('The Signal'), findsOneWidget); From fecc5b0d270f5d635369ced3add4ea2c7a8907c4 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 19 Oct 2026 15:50:15 +0800 Subject: [PATCH 3/4] =?UTF-8?q?feat(ui):=20=E6=96=B0=E5=A2=9E=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E5=85=A5=E5=8F=A3=E4=B8=8E=E6=88=91=E7=9A=84=E9=A1=B5?= =?UTF-8?q?=E9=A2=84=E8=A7=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/auth/auth_view.dart | 170 ++- lib/pages/mine/mine_view.dart | 1328 ++++++++++--------- test/account/mine_account_entry_test.dart | 2 + test/auth/auth_k21_test.dart | 3 + test/auth/auth_landing_test.dart | 65 + test/auth/auth_page_test.dart | 3 + test/auth/auth_visual_test.dart | 26 +- test/mine/mine_preview_layout_test.dart | 69 + test/notice/notices_entry_test.dart | 2 + test/watch/watch_history_provider_test.dart | 2 + 10 files changed, 1021 insertions(+), 649 deletions(-) create mode 100644 test/auth/auth_landing_test.dart create mode 100644 test/mine/mine_preview_layout_test.dart diff --git a/lib/pages/auth/auth_view.dart b/lib/pages/auth/auth_view.dart index 555d495..5ddbde0 100644 --- a/lib/pages/auth/auth_view.dart +++ b/lib/pages/auth/auth_view.dart @@ -60,6 +60,7 @@ class _AuthPageState extends State { late final FocusNode _emailFocus; late final FocusNode _passwordFocus; bool _obscurePassword = true; + bool _emailEntryOpen = false; @override void initState() { @@ -243,7 +244,13 @@ class _AuthPageState extends State { tooltip: MaterialLocalizations.of( context, ).backButtonTooltip, - onPressed: () => Navigator.of(context).pop(), + onPressed: () { + if (_emailEntryOpen) { + setState(() => _emailEntryOpen = false); + } else { + Navigator.of(context).pop(); + } + }, icon: const Icon( Icons.arrow_back_rounded, color: AppColors.onPrimary, @@ -272,16 +279,107 @@ class _AuthPageState extends State { child: child, ), ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _AuthBrand(tagline: l10n.authBrandTagline, compact: compact), - SizedBox(height: compact ? _compactBrandGap : 28), - _authCard(l10n), - ], - ), + child: _emailEntryOpen + ? Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _AuthBrand(tagline: l10n.authBrandTagline, compact: compact), + SizedBox(height: compact ? _compactBrandGap : 28), + _authCard(l10n), + ], + ) + : _landing(l10n), ); + Widget _landing(AppLocalizations l10n) => Column( + key: const Key('auth-landing'), + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Center( + child: Container( + key: const Key('auth-brand-logo'), + width: 64, + height: 64, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(18), + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [AppColors.blue, AppColors.secondary], + ), + ), + child: const Icon( + Icons.play_arrow_rounded, + color: AppColors.onPrimary, + size: 32, + ), + ), + ), + const SizedBox(height: 14), + Text( + l10n.fruitShortsBrand, + textAlign: TextAlign.center, + style: const TextStyle( + fontFamily: 'Outfit', + color: AppColors.onSurface, + fontSize: 20, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 6), + Text( + l10n.authLandingSubtitle, + textAlign: TextAlign.center, + style: const TextStyle(color: Color(0xFF9A96A3), fontSize: 11), + ), + const SizedBox(height: 22), + _AuthEntryButton( + key: const Key('auth-entry-google'), + label: 'G ${l10n.authContinueGoogle}', + background: Colors.white, + foreground: const Color(0xFF1A1A1A), + leadingColor: const Color(0xFF4285F4), + onTap: () => _comingSoon(l10n), + ), + const SizedBox(height: 11), + _AuthEntryButton( + key: const Key('auth-entry-phone'), + label: l10n.authPhoneSignIn, + icon: Icons.phone_android_rounded, + background: AppColors.input, + foreground: AppColors.onSurface, + onTap: () => _comingSoon(l10n), + ), + const SizedBox(height: 11), + _AuthEntryButton( + key: const Key('auth-entry-email'), + label: l10n.authEmailSignIn, + icon: Icons.mail_outline_rounded, + background: AppColors.input, + foreground: AppColors.onSurface, + onTap: () => setState(() => _emailEntryOpen = true), + ), + const SizedBox(height: 18), + Text( + l10n.authLegal, + key: const Key('auth-legal'), + textAlign: TextAlign.center, + style: const TextStyle( + color: Color(0xFF6B6772), + fontSize: 9, + height: 1.6, + ), + ), + ], + ); + + void _comingSoon(AppLocalizations l10n) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l10n.comingSoon))); + } + Widget _authCard(AppLocalizations l10n) => _AuthCard( state: _AuthCardState( l10n: l10n, @@ -320,3 +418,57 @@ class _AuthPageState extends State { setState(() => _logic.toggleMode()); } } + +class _AuthEntryButton extends StatelessWidget { + const _AuthEntryButton({ + super.key, + required this.label, + required this.background, + required this.foreground, + required this.onTap, + this.icon, + this.leadingColor, + }); + + final String label; + final Color background; + final Color foreground; + final VoidCallback onTap; + final IconData? icon; + final Color? leadingColor; + + @override + Widget build(BuildContext context) { + return Material( + color: background, + borderRadius: BorderRadius.circular(9), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: SizedBox( + height: 44, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (icon != null) + Icon(icon, size: 16, color: leadingColor ?? foreground), + if (icon != null) const SizedBox(width: 7), + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: foreground, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/pages/mine/mine_view.dart b/lib/pages/mine/mine_view.dart index 8091ab5..3a21c53 100644 --- a/lib/pages/mine/mine_view.dart +++ b/lib/pages/mine/mine_view.dart @@ -6,27 +6,28 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import '../../core/formatting/localized_content.dart'; +import '../../core/config/locale_controller.dart'; import '../../core/data/models/drama.dart'; +import '../../core/data/models/watch_progress.dart'; import '../../core/data/repositories/watch_history_repository.dart'; import '../../core/data/services/auth_controller.dart'; -import '../../core/data/services/interaction_controller.dart'; import '../../core/data/services/favorite_controller.dart'; +import '../../core/data/services/interaction_controller.dart'; import '../../core/formatting/error_text.dart'; -import '../../core/widgets/empty_state.dart'; -import '../../core/widgets/app_bottom_nav.dart'; -import '../../core/widgets/remote_image.dart'; +import '../../core/formatting/localized_content.dart'; import '../../core/theme/app_colors.dart'; -import '../../core/theme/app_dimens.dart'; import '../../core/theme/app_theme.dart'; +import '../../core/widgets/app_bottom_nav.dart'; +import '../../core/widgets/empty_state.dart'; +import '../../core/widgets/remote_image.dart'; import '../../l10n/app_localizations.dart'; -import 'mine_binding.dart'; -import 'mine_logic.dart'; -import '../watch_records/watch_records_page.dart'; +import '../account/account_entry_card.dart'; +import '../downloads/downloads_page.dart'; import '../favorites/favorites_page.dart'; import '../notices/notices_page.dart'; -import '../downloads/downloads_page.dart'; -import '../account/account_entry_card.dart'; +import '../watch_records/watch_records_page.dart'; +import 'mine_binding.dart'; +import 'mine_logic.dart'; class MyPage extends StatefulWidget { const MyPage({ @@ -117,40 +118,27 @@ class _MyPageState extends State { children: [ _topBar(l10n), Expanded( - child: FutureBuilder( - future: logic.position, - builder: (context, snapshot) { - final position = snapshot.data ?? Duration.zero; - final total = widget.duration > Duration.zero - ? widget.duration - : const Duration(seconds: 12); - final fraction = - (position.inMilliseconds / total.inMilliseconds).clamp( - 0.0, - 1.0, - ); - return ListView( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 20), - children: [ - if (widget.profile) ..._accountPage(l10n), - if (widget.profile) const SizedBox(height: 24), - _library(l10n, position, total, fraction), - if (!widget.profile) const SizedBox(height: 24), - if (!widget.profile) ..._accountPage(l10n), - const SizedBox(height: 24), - Center( - child: Text( - l10n.brandMark, - style: const TextStyle( - color: AppColors.muted, - fontSize: AppDimens.caption, - letterSpacing: 1.3, - ), - ), - ), - ], - ); - }, + child: ListView( + padding: const EdgeInsets.fromLTRB(14, 2, 14, 18), + children: [ + _userRow(l10n), + const SizedBox(height: 12), + _wallet(l10n), + if (_continueDrama != null) ...[ + const SizedBox(height: 12), + _continueCard(l10n, _continueDrama!, _continueRecord!), + ], + const SizedBox(height: 14), + _library(l10n), + const SizedBox(height: 14), + _languageRow(l10n), + if (widget.signedIn) ...[ + const SizedBox(height: 12), + const AccountEntryCard(), + ], + const SizedBox(height: 12), + _menuCard(l10n), + ], ), ), if (!widget.embedded) _bottomNav(l10n), @@ -166,124 +154,102 @@ class _MyPageState extends State { } Widget _topBar(AppLocalizations l10n) => SizedBox( - height: 54, + height: 46, child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), + padding: const EdgeInsets.symmetric(horizontal: 14), child: Row( children: [ - const Icon(Icons.play_circle_fill_rounded, color: _accent, size: 27), - const SizedBox(width: 7), + 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( - widget.profile ? l10n.myTab : l10n.myListTab, + l10n.myTab, style: const TextStyle( - fontSize: AppDimens.pageTitle, + fontFamily: 'Outfit', + color: AppColors.onSurface, + fontSize: 15, fontWeight: FontWeight.w800, ), ), const Spacer(), - IconButton( + _RoundHeaderButton( tooltip: l10n.search, - onPressed: () => logic.act( + icon: Icons.search_rounded, + onTap: () => logic.act( context, MyPageAction.discover, embedded: widget.embedded, onAction: widget.onAction, ), - icon: const Icon(Icons.search_rounded, size: 23), ), - const SizedBox(width: 6), - const CircleAvatar( - radius: 14, - backgroundColor: AppColors.raised, - child: Icon(Icons.person_rounded, color: Colors.white, size: 19), + const SizedBox(width: 7), + Container( + width: 28, + height: 28, + decoration: const BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFF6A5CFF), AppColors.blue], + ), + ), + child: const Icon( + Icons.person_rounded, + color: AppColors.onSurface, + size: 15, + ), ), ], ), ), ); - List _accountPage(AppLocalizations l10n) => [ - if (widget.signedIn) _signedInRow(l10n) else _guestRow(l10n), - if (widget.signedIn) ...[ - const SizedBox(height: 8), - _wallet(l10n), - const SizedBox(height: 4), - const AccountEntryCard(), - ], - const SizedBox(height: 4), - _menuCard(l10n), - const SizedBox(height: 8), - Text( - l10n.preferencesHeading, - style: const TextStyle( - color: AppColors.muted, - fontSize: AppDimens.caption, - fontWeight: FontWeight.w800, - letterSpacing: 1.1, - ), - ), - const SizedBox(height: 8), - ListTile( - contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.language_rounded, color: AppColors.blueSoft), - title: Text(l10n.appLanguage), - trailing: const Icon(Icons.chevron_right_rounded), - onTap: () => logic.showLanguage( - context, - showServerSetting: widget.debugShowSetting, - ), - ), - ]; - - Widget _guestRow(AppLocalizations l10n) { + Widget _userRow(AppLocalizations l10n) { + final user = widget.signedIn ? context.watch().user : null; + final name = widget.signedIn + ? ((user?.nickname.isNotEmpty ?? false) + ? user!.nickname + : l10n.localViewer) + : l10n.localViewer; + final subtitle = widget.signedIn + ? ((user?.uid.isNotEmpty ?? false) + ? 'ID: ${user!.uid}' + : l10n.myAccount) + : l10n.logInHint; return Row( + key: const Key('mine-user-row'), children: [ - const CircleAvatar( - radius: 28, - backgroundColor: AppColors.raised, - child: Icon(Icons.person_rounded, color: Colors.white, size: 30), - ), - const SizedBox(width: 12), - Expanded( - child: Text( - l10n.logInHint, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600), + Container( + width: 48, + height: 48, + decoration: const BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFF6A5CFF), AppColors.blue], + ), + ), + child: const Icon( + Icons.person_rounded, + color: AppColors.onSurface, + size: 22, ), ), - const SizedBox(width: 8), - TextButton( - onPressed: widget.onSignIn, - style: TextButton.styleFrom( - foregroundColor: Colors.white, - backgroundColor: AppColors.blue, - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), - shape: const StadiumBorder(), - ), - child: Text(l10n.logIn), - ), - ], - ); - } - - Widget _signedInRow(AppLocalizations l10n) { - // 显示真实登录账号(昵称 + uid);拿不到才回落到演示文案 - final user = context.watch().user; - final name = (user?.nickname.isNotEmpty ?? false) - ? user!.nickname - : l10n.mockUserName; - final uidText = (user?.uid.isNotEmpty ?? false) - ? 'ID: ${user!.uid}' - : l10n.mockUserId; - return Row( - children: [ - const CircleAvatar( - radius: 28, - backgroundColor: AppColors.raised, - child: Icon(Icons.person_rounded, color: Colors.white, size: 30), - ), - const SizedBox(width: 12), + const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -293,120 +259,498 @@ class _MyPageState extends State { maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.w600, + color: AppColors.onSurface, + fontSize: 15, + fontWeight: FontWeight.w800, ), ), const SizedBox(height: 2), Text( - uidText, - style: const TextStyle(color: AppColors.muted, fontSize: 12), + subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Color(0xFFBBBBBB), fontSize: 10), ), ], ), ), - TextButton( - onPressed: widget.onSignOut ?? () => _mockSnack(l10n), - style: TextButton.styleFrom( - backgroundColor: AppColors.raised, - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), - shape: const StadiumBorder(), + if (widget.signedIn) + TextButton( + onPressed: widget.onSignOut, + style: TextButton.styleFrom( + foregroundColor: AppColors.muted, + backgroundColor: const Color(0xFF24252A), + minimumSize: const Size(52, 30), + padding: const EdgeInsets.symmetric(horizontal: 10), + shape: const StadiumBorder(), + textStyle: const TextStyle(fontSize: 10), + ), + child: Text(l10n.signOut), + ) + else + TextButton( + key: const Key('mine-sign-in'), + onPressed: widget.onSignIn, + style: TextButton.styleFrom( + foregroundColor: AppColors.cream, + backgroundColor: const Color(0xFF24252A), + minimumSize: const Size(64, 30), + padding: const EdgeInsets.symmetric(horizontal: 10), + shape: const StadiumBorder(), + textStyle: const TextStyle(fontSize: 10), + ), + child: Text('${l10n.logIn}/${l10n.signUp}'), ), - child: Text(l10n.signOut), - ), ], ); } Widget _wallet(AppLocalizations l10n) { + return InkWell( + key: const Key('mine-wallet'), + onTap: () => _mockSnack(l10n), + borderRadius: BorderRadius.circular(13), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.goldCard, + borderRadius: BorderRadius.circular(13), + ), + child: Row( + children: [ + const Icon( + Icons.monetization_on_rounded, + color: AppColors.gold, + size: 20, + ), + const SizedBox(width: 9), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.coins, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 12, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 2), + Text( + '0 ${l10n.coins} · ${l10n.topUpWallet}', + style: const TextStyle( + color: AppColors.accent, + fontSize: 9, + ), + ), + ], + ), + ), + const Icon( + Icons.chevron_right_rounded, + color: Color(0xFF666666), + size: 16, + ), + ], + ), + ), + ); + } + + WatchProgress? get _continueRecord { + for (final record in logic.recent) { + if (!record.finished && record.hasPosition) return record; + } + return null; + } + + Drama? get _continueDrama { + final record = _continueRecord; + if (record == null) return null; + for (final drama in [...?widget.dramas, widget.drama]) { + if (drama.id == record.dramaId) return drama; + } + return null; + } + + Widget _continueCard( + AppLocalizations l10n, + Drama drama, + WatchProgress record, + ) { return Container( - padding: const EdgeInsets.all(AppDimens.cardPadding), + key: const Key('mine-continue-watching'), + padding: const EdgeInsets.all(9), decoration: BoxDecoration( color: AppColors.card, - borderRadius: BorderRadius.circular(AppDimens.cardRadius), + borderRadius: BorderRadius.circular(12), ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + child: Row( children: [ - Text( - l10n.myAccount, - style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w800), + _poster(drama, width: 50, height: 64, radius: 7), + const SizedBox(width: 9), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.continueWatching, + style: const TextStyle( + color: AppColors.accent, + fontSize: 9, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 2), + Text( + localizedDramaTitle(l10n, drama), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 12, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 2), + Text( + '${_episodeText(drama, record)} · ' + '${_clock(record.position)} / ${_clock(record.duration)}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Color(0xFFBBBBBB), fontSize: 9), + ), + ], + ), ), - const SizedBox(height: 12), - Row( - children: [ - Expanded( - child: _accountTile( - label: l10n.coins, - value: '\$ 0', - action: l10n.topUpWallet, - color: AppColors.gold, - onPressed: () => _mockSnack(l10n), + const SizedBox(width: 8), + SizedBox( + width: 60, + height: 28, + child: FilledButton( + onPressed: () => _resume(drama), + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: AppColors.onPrimary, + padding: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(9), + ), + textStyle: const TextStyle( + fontSize: 10, + fontWeight: FontWeight.w700, ), ), - const SizedBox(width: 10), - Expanded( - child: _accountTile( - label: l10n.vipMembership, - value: l10n.notActive, - action: l10n.joinVip, - color: AppColors.blue, - onPressed: () => _mockSnack(l10n), - ), - ), - ], + child: Text(l10n.resume), + ), ), ], ), ); } - void _mockSnack(AppLocalizations l10n) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(l10n.mockNotice))); + Widget _library(AppLocalizations l10n) { + final tabs = [l10n.history, l10n.watchlist, l10n.likedTab]; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + for (var index = 0; index < tabs.length; index++) + Expanded( + child: Semantics( + selected: logic.section == index, + button: true, + child: InkWell( + key: Key('mine-library-tab-$index'), + onTap: () => logic.selectSection(index), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Text( + tabs[index], + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: logic.section == index + ? AppColors.blue + : const Color(0xFFE4DBDD), + fontSize: 14, + fontWeight: FontWeight.w800, + ), + ), + ), + ), + ), + ), + ], + ), + const SizedBox(height: 10), + if (logic.section == 1 && context.watch().loading) + const Center( + child: Padding( + padding: EdgeInsets.all(12), + child: CircularProgressIndicator(strokeWidth: 2), + ), + ) + else if (logic.section == 1) + ..._favoriteCards(l10n) + else if (logic.section == 2) + ..._likedCards(l10n) + else if (logic.recentLoading) + const Center( + child: Padding( + padding: EdgeInsets.all(12), + child: CircularProgressIndicator(strokeWidth: 2), + ), + ) + else if (_recentDramas.isEmpty) + EmptyState( + key: const Key('mine-recent-empty'), + icon: Icons.history_rounded, + title: l10n.noStories, + subtitle: l10n.emptyHint, + compact: true, + ) + else + for (final drama in _recentDramas) ...[ + _historyCard(l10n, drama), + const SizedBox(height: 8), + ], + ], + ); } - Widget _accountTile({ - required String label, - required String value, - required String action, - required Color color, - required VoidCallback onPressed, - }) { - return Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: AppColors.ink, - borderRadius: BorderRadius.circular(AppDimens.cardRadius), - border: Border.all(color: color.withValues(alpha: 0.45)), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(label, style: TextStyle(color: color, fontSize: 12)), - const SizedBox(height: 6), - Text( - value, - style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w800), - ), - const SizedBox(height: 10), - SizedBox( - width: double.infinity, - child: FilledButton( - onPressed: onPressed, - style: FilledButton.styleFrom( - backgroundColor: color, - foregroundColor: color == AppColors.blue - ? Colors.white - : AppColors.onGold, - shape: const StadiumBorder(), + List _favoriteCards(AppLocalizations l10n) { + final favorites = context.watch(); + if (favorites.error != null && favorites.cards.isEmpty) { + return [ + EmptyState( + icon: Icons.cloud_off_rounded, + title: errorText(l10n, favorites.error, l10n.favoritesError), + actionLabel: l10n.retry, + onAction: () => unawaited(favorites.load()), + compact: true, + ), + ]; + } + if (favorites.cards.isEmpty) { + return [ + EmptyState( + key: const Key('mine-saved-empty'), + icon: Icons.bookmark_border_rounded, + title: l10n.noSavedShows, + subtitle: l10n.emptyHint, + compact: true, + ), + ]; + } + return [ + for (final drama in favorites.cards) ...[ + _smallDramaCard(l10n, drama), + const SizedBox(height: 8), + ], + ]; + } + + List _likedCards(AppLocalizations l10n) { + final interactions = context.watch(); + final dramas = [ + ...?widget.dramas, + widget.drama, + ].where((drama) => interactions.isLiked(drama.id)).toList(); + if (dramas.isEmpty) { + return [ + EmptyState( + key: const Key('mine-saved-empty'), + icon: Icons.favorite_border_rounded, + title: l10n.noSavedShows, + subtitle: l10n.emptyHint, + compact: true, + ), + ]; + } + return [ + for (final drama in dramas) ...[ + _smallDramaCard(l10n, drama), + const SizedBox(height: 8), + ], + ]; + } + + Widget _historyCard(AppLocalizations l10n, Drama drama) { + final record = _recordFor(drama.id); + return InkWell( + key: Key('mine-history-card-${drama.id}'), + onTap: () => _resume(drama), + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + _poster(drama, width: 48, height: 60, radius: 7), + const SizedBox(width: 9), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + localizedDramaTitle(l10n, drama), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 11, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 3), + Text( + record == null + ? l10n.episodeCount(drama.episodeCount) + : '${_episodeText(drama, record)} · ' + '${_clock(record.position)} / ${_clock(record.duration)}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Color(0xFFBBBBBB), + fontSize: 9, + ), + ), + ], ), - child: Text(action, maxLines: 1, overflow: TextOverflow.ellipsis), ), - ), - ], + Container( + width: 26, + height: 26, + decoration: const BoxDecoration( + color: Color(0xFF313137), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.play_arrow_rounded, + color: AppColors.blue, + size: 15, + ), + ), + ], + ), + ), + ); + } + + Widget _smallDramaCard(AppLocalizations l10n, Drama drama) { + return InkWell( + onTap: () { + if (widget.onDramaSelected != null) { + widget.onDramaSelected!(drama); + } else { + _resume(drama); + } + }, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + _poster(drama, width: 48, height: 60, radius: 7), + const SizedBox(width: 9), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + localizedDramaTitle(l10n, drama), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 11, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 3), + Text( + l10n.episodeCount(drama.episodeCount), + style: const TextStyle( + color: Color(0xFFBBBBBB), + fontSize: 9, + ), + ), + ], + ), + ), + const Icon( + Icons.chevron_right_rounded, + color: AppColors.muted, + size: 16, + ), + ], + ), + ), + ); + } + + Widget _languageRow(AppLocalizations l10n) { + final locale = context.watch(); + final code = locale.locale?.languageCode ?? 'id'; + final value = switch (code) { + 'zh' => l10n.simplifiedChinese, + 'en' => l10n.english, + _ => l10n.bahasaIndonesia, + }; + return InkWell( + key: const Key('mine-language'), + onTap: () => logic.showLanguage( + context, + showServerSetting: widget.debugShowSetting, + ), + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + const Icon( + Icons.language_rounded, + color: AppColors.muted, + size: 14, + ), + const SizedBox(width: 9), + Expanded( + child: Text( + l10n.appLanguage, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 11, + ), + ), + ), + Text( + value, + style: const TextStyle(color: AppColors.accent, fontSize: 10), + ), + const SizedBox(width: 3), + const Icon( + Icons.chevron_right_rounded, + color: AppColors.accent, + size: 16, + ), + ], + ), ), ); } @@ -414,97 +758,42 @@ class _MyPageState extends State { Widget _menuCard(AppLocalizations l10n) { return Material( color: AppColors.card, - borderRadius: BorderRadius.circular(16), + borderRadius: BorderRadius.circular(12), clipBehavior: Clip.antiAlias, child: Column( children: [ - ListTile( - dense: true, - visualDensity: VisualDensity.compact, - minTileHeight: AppDimens.tapTarget, - leading: const Icon( - Icons.card_giftcard_rounded, - color: AppColors.blueSoft, - ), - title: Text(l10n.earnRewards), - trailing: Text( - l10n.freeBonus, - style: const TextStyle(color: AppColors.blueSoft, fontSize: 12), - ), - onTap: () => _mockSnack(l10n), - ), - const Divider(height: 1, color: AppColors.outlineVariantDark), - ListTile( - dense: true, - visualDensity: VisualDensity.compact, - minTileHeight: AppDimens.tapTarget, + _menuTile( + icon: Icons.notifications_none_rounded, + title: l10n.notices, key: const Key('mine-notices'), - leading: const Icon( - Icons.notifications_none_rounded, - color: AppColors.blueSoft, - ), - title: Text(l10n.notices), - trailing: const Icon(Icons.chevron_right_rounded), onTap: () => Navigator.of(context).push( MaterialPageRoute(builder: (_) => const NoticesPage()), ), ), - const Divider(height: 1, color: AppColors.outlineVariantDark), - ListTile( - dense: true, - visualDensity: VisualDensity.compact, - minTileHeight: AppDimens.tapTarget, - leading: const Icon( - Icons.history_rounded, - color: AppColors.blueSoft, - ), - title: Text(l10n.watchHistory), - trailing: const Icon(Icons.chevron_right_rounded), + _menuTile( + icon: Icons.history_rounded, + title: l10n.watchHistory, onTap: () => Navigator.of(context).push( MaterialPageRoute(builder: (_) => const WatchRecordsPage()), ), ), - const Divider(height: 1, color: AppColors.outlineVariantDark), - ListTile( - dense: true, - visualDensity: VisualDensity.compact, - minTileHeight: AppDimens.tapTarget, - leading: const Icon( - Icons.favorite_outline_rounded, - color: AppColors.blueSoft, - ), - title: Text(l10n.myFavorites), - trailing: const Icon(Icons.chevron_right_rounded), + _menuTile( + icon: Icons.favorite_outline_rounded, + title: l10n.myFavorites, onTap: () => Navigator.of(context).push( MaterialPageRoute(builder: (_) => const FavoritesPage()), ), ), - const Divider(height: 1, color: AppColors.outlineVariantDark), - ListTile( - dense: true, - visualDensity: VisualDensity.compact, - minTileHeight: AppDimens.tapTarget, - leading: const Icon( - Icons.download_outlined, - color: AppColors.blueSoft, - ), - title: Text(l10n.downloads), - trailing: const Icon(Icons.chevron_right_rounded), + _menuTile( + icon: Icons.download_outlined, + title: l10n.downloads, onTap: () => Navigator.of(context).push( MaterialPageRoute(builder: (_) => const DownloadsPage()), ), ), - const Divider(height: 1, color: AppColors.outlineVariantDark), - ListTile( - dense: true, - visualDensity: VisualDensity.compact, - minTileHeight: AppDimens.tapTarget, - leading: const Icon( - Icons.settings_rounded, - color: AppColors.blueSoft, - ), - title: Text(l10n.settings), - trailing: const Icon(Icons.chevron_right_rounded), + _menuTile( + icon: Icons.settings_rounded, + title: l10n.settings, onTap: () => logic.showLanguage( context, showServerSetting: widget.debugShowSetting, @@ -515,346 +804,30 @@ class _MyPageState extends State { ); } - Widget _library( - AppLocalizations l10n, - Duration position, - Duration total, - double fraction, - ) { - final tabs = [l10n.history, l10n.watchlist, l10n.likedTab]; - final filters = [l10n.all, l10n.inProgress, l10n.completed]; - final hasHistory = - logic.filter == 0 || - (logic.filter == 1 && fraction < 0.99) || - (logic.filter == 2 && fraction >= 0.99); - final interactions = context.watch(); - // 收藏以服务端为准;剧集点赞暂按当前会话状态过滤 feed。 - final favorites = context.watch(); - final isWatchlist = logic.section == 1; - final markedDramas = isWatchlist - ? favorites.cards - : (widget.dramas ?? [widget.drama]) - .where((drama) => interactions.isLiked(drama.id)) - .toList(); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - for (var index = 0; index < tabs.length; index++) ...[ - Semantics( - selected: logic.section == index, - button: true, - child: InkWell( - key: Key('mine-library-tab-$index'), - onTap: () { - logic.selectSection(index); - // 打开收藏页签时以服务端为准刷新一次(多设备/多入口都能对齐) - if (index == 1) unawaited(favorites.load()); - }, - borderRadius: BorderRadius.circular(AppDimens.pillRadius), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 14, - vertical: 8, - ), - decoration: BoxDecoration( - color: logic.section == index - ? AppColors.blue - : AppColors.raised, - borderRadius: BorderRadius.circular( - AppDimens.pillRadius, - ), - ), - child: Text( - tabs[index], - style: const TextStyle( - color: Colors.white, - fontSize: AppDimens.body, - fontWeight: FontWeight.w700, - ), - ), - ), - ), - ), - if (index < tabs.length - 1) const SizedBox(width: 18), - ], - ], - ), - ), - const SizedBox(height: 17), - if (logic.section == 0) - SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - for (var index = 0; index < filters.length; index++) - Padding( - padding: const EdgeInsets.only(right: 7), - child: ChoiceChip( - label: Text(filters[index]), - selected: logic.filter == index, - onSelected: (_) => logic.selectFilter(index), - showCheckmark: false, - materialTapTargetSize: MaterialTapTargetSize.padded, - backgroundColor: AppColors.raised, - selectedColor: AppColors.blue, - labelStyle: TextStyle( - color: logic.filter == index - ? Colors.white - : AppColors.muted, - fontSize: AppDimens.caption, - fontWeight: FontWeight.w700, - ), - side: BorderSide.none, - ), - ), - ], - ), - ), - if (logic.section == 0) const SizedBox(height: 11), - if (logic.section == 0 && hasHistory) - InkWell( - onTap: () => logic.act( - context, - MyPageAction.resume, - embedded: widget.embedded, - onAction: widget.onAction, - ), - borderRadius: BorderRadius.circular(AppDimens.innerRadius), - child: Container( - padding: const EdgeInsets.all(AppDimens.innerPadding), - decoration: BoxDecoration( - color: AppColors.card, - borderRadius: BorderRadius.circular(AppDimens.innerRadius), - ), - child: Row( - children: [ - _poster(width: 74, height: 103), - const SizedBox(width: 11), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - localizedDramaTitle(l10n, widget.drama), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: AppDimens.listTitle, - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 5), - Text( - l10n.dramaTag, - style: const TextStyle( - color: AppColors.cream, - fontSize: AppDimens.caption, - ), - ), - const SizedBox(height: 32), - Text( - '${l10n.episodeLabel(1)} · ${_clock(position)} / ${_clock(total)}', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.blueSoft, - fontSize: AppDimens.caption, - ), - ), - ], - ), - ), - const CircleAvatar( - radius: 15, - backgroundColor: AppColors.raised, - child: Icon( - Icons.play_arrow_rounded, - color: _accent, - size: 20, - ), - ), - ], - ), - ), - ) - else if (isWatchlist && favorites.loading) - const Padding( - padding: EdgeInsets.symmetric(vertical: 22), - child: Center( - child: SizedBox( - width: 22, - height: 22, - child: CircularProgressIndicator(strokeWidth: 2), - ), - ), - ) - else if (isWatchlist && favorites.error != null) - EmptyState( - icon: Icons.cloud_off_rounded, - title: errorText(l10n, favorites.error, l10n.favoritesError), - actionLabel: l10n.retry, - onAction: () => unawaited(favorites.load()), - compact: true, - ) - else if (logic.section != 0 && markedDramas.isNotEmpty) - for (final drama in markedDramas) ...[ - _markedDramaCard(drama, l10n), - const SizedBox(height: 9), - ] - else - EmptyState( - key: const Key('mine-saved-empty'), - icon: Icons.bookmark_border_rounded, - title: l10n.noSavedShows, - subtitle: l10n.emptyHint, - ), - // 最近观看(服务端 /app/watch/recent):只渲染能在已加载目录里匹配到的剧; - // 匹配不到的(例如不在当前首页分区里)先不显示,K2 的记录页会做独立请求。 - if (logic.section == 0) ...[ - const SizedBox(height: 18), - Text( - l10n.history, - style: const TextStyle( - color: Colors.white, - fontSize: AppDimens.body, - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 9), - if (logic.positionFailed) - Row( - children: [ - Expanded( - child: Text( - l10n.progressError, - style: const TextStyle(color: AppColors.cream), - ), - ), - TextButton( - onPressed: logic.refreshPosition, - child: Text(l10n.retry), - ), - ], - ) - else if (logic.recentLoading) - const Padding( - padding: EdgeInsets.symmetric(vertical: 12), - child: Center( - child: SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ), - ), - ) - else if (logic.recentError != null) - Row( - children: [ - Expanded( - child: Text( - errorText(l10n, logic.recentError, l10n.progressError), - style: const TextStyle(color: AppColors.cream), - ), - ), - TextButton( - onPressed: () => unawaited(logic.refreshRecent()), - child: Text(l10n.retry), - ), - ], - ) - else if (_recentDramas.isEmpty) - EmptyState( - key: const Key('mine-recent-empty'), - icon: Icons.history_rounded, - title: l10n.noStories, - subtitle: l10n.emptyHint, - ) - else - for (final drama in _recentDramas) ...[ - _markedDramaCard(drama, l10n), - const SizedBox(height: 9), - ], - ], - ], + Widget _menuTile({ + required IconData icon, + required String title, + required VoidCallback onTap, + Key? key, + }) { + return ListTile( + key: key, + dense: true, + minTileHeight: 44, + leading: Icon(icon, color: AppColors.secondary, size: 17), + title: Text( + title, + style: const TextStyle(color: AppColors.onSurface, fontSize: 11), + ), + trailing: const Icon( + Icons.chevron_right_rounded, + color: AppColors.muted, + size: 16, + ), + onTap: onTap, ); } - /// 服务端最近观看 → 已加载目录里的卡片(按 id 匹配,去重,当前剧也算一条)。 - List get _recentDramas { - final pool = [...?widget.dramas, widget.drama]; - final result = []; - for (final item in logic.recent) { - for (final drama in pool) { - if (drama.id == item.dramaId && - !result.any((picked) => picked.id == drama.id)) { - result.add(drama); - } - } - } - return result; - } - - Widget _markedDramaCard(Drama drama, AppLocalizations l10n) => InkWell( - onTap: () { - if (widget.onDramaSelected != null) { - widget.onDramaSelected!(drama); - } else if (drama.id == widget.drama.id) { - logic.act( - context, - MyPageAction.resume, - embedded: widget.embedded, - onAction: widget.onAction, - ); - } - }, - borderRadius: BorderRadius.circular(AppDimens.innerRadius), - child: Container( - padding: const EdgeInsets.all(AppDimens.innerPadding), - decoration: BoxDecoration( - color: AppColors.card, - borderRadius: BorderRadius.circular(AppDimens.innerRadius), - ), - child: Row( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(AppDimens.innerRadius), - child: RemoteImage(drama.posterUrl, width: 74, height: 103), - ), - const SizedBox(width: 11), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - localizedDramaTitle(l10n, drama), - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: AppDimens.listTitle, - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 7), - Text( - l10n.episodeCount(drama.episodeCount), - style: const TextStyle( - color: AppColors.cream, - fontSize: AppDimens.caption, - ), - ), - ], - ), - ), - const Icon(Icons.chevron_right_rounded, color: AppColors.cream), - ], - ), - ), - ); - Widget _bottomNav(AppLocalizations l10n) => AppBottomNav( items: [ AppBottomNavItem( @@ -892,15 +865,100 @@ class _MyPageState extends State { ], ); - Widget _poster({required double width, required double height}) => ClipRRect( - borderRadius: BorderRadius.circular(7), - child: RemoteImage(widget.drama.posterUrl, width: width, height: height), + List get _recentDramas { + final pool = [...?widget.dramas, widget.drama]; + final result = []; + for (final item in logic.recent) { + for (final drama in pool) { + if (drama.id == item.dramaId && + !result.any((picked) => picked.id == drama.id)) { + result.add(drama); + } + } + } + return result; + } + + WatchProgress? _recordFor(String dramaId) { + for (final record in logic.recent) { + if (record.dramaId == dramaId) return record; + } + return null; + } + + String _episodeText(Drama drama, WatchProgress record) { + for (final episode in drama.episodes) { + if (episode.id == record.episodeId) { + return AppLocalizations.of( + context, + )!.episodeOfTotal(episode.number, drama.episodeCount); + } + } + return AppLocalizations.of(context)!.episodeCount(drama.episodeCount); + } + + void _resume(Drama drama) { + if (widget.onDramaSelected != null) { + widget.onDramaSelected!(drama); + } else { + logic.act( + context, + MyPageAction.resume, + embedded: widget.embedded, + onAction: widget.onAction, + ); + } + } + + void _mockSnack(AppLocalizations l10n) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l10n.mockNotice))); + } + + Widget _poster( + Drama drama, { + required double width, + required double height, + required double radius, + }) => ClipRRect( + borderRadius: BorderRadius.circular(radius), + child: RemoteImage(drama.posterUrl, width: width, height: height), ); + + String _clock(Duration duration) { + final minutes = duration.inMinutes.remainder(60).toString().padLeft(2, '0'); + final seconds = duration.inSeconds.remainder(60).toString().padLeft(2, '0'); + return '$minutes:$seconds'; + } } -String _clock(Duration duration) { - final seconds = duration.inSeconds; - return '${(seconds ~/ 60).toString().padLeft(2, '0')}:${(seconds % 60).toString().padLeft(2, '0')}'; -} +class _RoundHeaderButton extends StatelessWidget { + const _RoundHeaderButton({ + required this.tooltip, + required this.icon, + required this.onTap, + }); -const _accent = AppColors.blueSoft; + final String tooltip; + final IconData icon; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 28, + height: 28, + child: IconButton( + tooltip: tooltip, + onPressed: onTap, + padding: EdgeInsets.zero, + style: IconButton.styleFrom( + backgroundColor: const Color(0xFF2C2D33), + foregroundColor: AppColors.onSurface, + ), + icon: Icon(icon, size: 14), + ), + ); + } +} diff --git a/test/account/mine_account_entry_test.dart b/test/account/mine_account_entry_test.dart index 7097af0..d0612ca 100644 --- a/test/account/mine_account_entry_test.dart +++ b/test/account/mine_account_entry_test.dart @@ -46,6 +46,8 @@ void main() { await tester.tap(find.byKey(const Key('tab-my'))); await tester.pumpAndSettle(); + await tester.drag(find.byType(ListView), const Offset(0, -520)); + await tester.pumpAndSettle(); expect(find.byKey(const Key('mine-account-card')), findsOneWidget); expect(find.byKey(const Key('mine-account-profile')), findsOneWidget); diff --git a/test/auth/auth_k21_test.dart b/test/auth/auth_k21_test.dart index 14fefef..6312819 100644 --- a/test/auth/auth_k21_test.dart +++ b/test/auth/auth_k21_test.dart @@ -82,4 +82,7 @@ Future _pumpAuth(WidgetTester tester) async { ), ); await tester.pumpAndSettle(); + await tester.ensureVisible(find.byKey(const Key('auth-entry-email'))); + await tester.tap(find.byKey(const Key('auth-entry-email'))); + await tester.pumpAndSettle(); } diff --git a/test/auth/auth_landing_test.dart b/test/auth/auth_landing_test.dart new file mode 100644 index 0000000..af8b6d7 --- /dev/null +++ b/test/auth/auth_landing_test.dart @@ -0,0 +1,65 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.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 'package:fruitshorts/core/data/remote/api_client.dart'; +import 'package:fruitshorts/core/data/remote/auth_api.dart'; +import 'package:fruitshorts/core/data/services/auth_controller.dart'; +import 'package:fruitshorts/core/data/services/auth_session_store.dart'; +import 'package:fruitshorts/core/platform/haptics.dart'; +import 'package:fruitshorts/l10n/app_localizations.dart'; +import 'package:fruitshorts/pages/auth/auth_view.dart'; + +import '../support/fake_haptics.dart'; +import '../support/fake_http_adapter.dart'; + +void main() { + testWidgets('should_show_three_login_entries_and_open_email_form', ( + tester, + ) async { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + final auth = AuthController( + AuthApi( + ApiClient( + baseUrl: 'http://127.0.0.1:1', + adapter: FakeHttpAdapter((_) async => FakeHttpResponse('{}', 500)), + ), + ), + AuthSessionStore(), + ); + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: auth), + Provider.value(value: FakeHaptics()), + ], + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const AuthPage(), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('auth-entry-google')), findsOneWidget); + expect(find.byKey(const Key('auth-entry-phone')), findsOneWidget); + expect(find.byKey(const Key('auth-entry-email')), findsOneWidget); + expect(find.byKey(const Key('auth-legal')), findsOneWidget); + + await tester.tap(find.byKey(const Key('auth-entry-google'))); + await tester.pumpAndSettle(); + expect(find.text('Coming soon'), findsOneWidget); + + await tester.tap(find.byKey(const Key('auth-entry-email'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('auth-email')), findsOneWidget); + expect(find.byKey(const Key('auth-password')), findsOneWidget); + }); +} diff --git a/test/auth/auth_page_test.dart b/test/auth/auth_page_test.dart index ffa24e0..3a5851c 100644 --- a/test/auth/auth_page_test.dart +++ b/test/auth/auth_page_test.dart @@ -59,6 +59,9 @@ void main() { ), ); await tester.pumpAndSettle(); + await tester.ensureVisible(find.byKey(const Key('auth-entry-email'))); + await tester.tap(find.byKey(const Key('auth-entry-email'))); + await tester.pumpAndSettle(); } testWidgets('should_show_email_error_and_stay_on_page_when_form_is_empty', ( diff --git a/test/auth/auth_visual_test.dart b/test/auth/auth_visual_test.dart index 5f5e052..10c94f4 100644 --- a/test/auth/auth_visual_test.dart +++ b/test/auth/auth_visual_test.dart @@ -29,6 +29,7 @@ void main() { WidgetTester tester, { AuthApi? api, FakeHaptics? haptics, + bool openEmail = true, }) async { final auth = AuthController( api ?? @@ -57,20 +58,32 @@ void main() { ), ); await tester.pump(const Duration(milliseconds: 220)); + if (openEmail) { + await tester.ensureVisible(find.byKey(const Key('auth-entry-email'))); + await tester.tap(find.byKey(const Key('auth-entry-email'))); + await tester.pumpAndSettle(); + } } testWidgets( 'should_render_branded_dark_layout_with_approved_tokens_when_page_opens', (tester) async { - await pumpAuthPage(tester); + await pumpAuthPage(tester, openEmail: false); - expect(find.byKey(const Key('auth-brand')), findsOneWidget); + expect(find.byKey(const Key('auth-landing')), findsOneWidget); expect(find.text('FruitShorts'), findsOneWidget); - expect(find.text('Short dramas made for Indonesia'), findsOneWidget); expect( - tester.getSize(find.byKey(const Key('auth-brand-mark'))), - const Size(48, 48), + find.text('Sign in to sync progress, favorites and coins'), + findsOneWidget, ); + expect( + tester.getSize(find.byKey(const Key('auth-brand-logo'))), + const Size(64, 64), + ); + expect(find.byKey(const Key('auth-entry-google')), findsOneWidget); + expect(find.byKey(const Key('auth-entry-phone')), findsOneWidget); + expect(find.byKey(const Key('auth-entry-email')), findsOneWidget); + expect(find.byKey(const Key('auth-legal')), findsOneWidget); final glow = tester.widget( find.byKey(const Key('auth-background-glow')), @@ -81,6 +94,9 @@ void main() { expect(color.a, lessThanOrEqualTo(0.18)); } + await tester.tap(find.byKey(const Key('auth-entry-email'))); + await tester.pumpAndSettle(); + final card = tester.widget(find.byKey(const Key('auth-card'))); final cardDecoration = card.decoration! as BoxDecoration; expect(cardDecoration.color, AppColors.card); diff --git a/test/mine/mine_preview_layout_test.dart b/test/mine/mine_preview_layout_test.dart new file mode 100644 index 0000000..cb9d013 --- /dev/null +++ b/test/mine/mine_preview_layout_test.dart @@ -0,0 +1,69 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.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 'package:fruitshorts/app.dart'; +import 'package:fruitshorts/core/data/models/watch_progress.dart'; +import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; +import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; +import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; + +import '../support/fake_catalog.dart'; +import '../support/fake_category.dart'; +import '../support/fake_haptics.dart'; +import '../support/fake_interaction_repositories.dart'; +import '../support/fake_notice_repository.dart'; +import '../support/widget_player.dart'; + +void main() { + testWidgets('should_render_me_user_wallet_continue_and_language_rows', ( + tester, + ) async { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final catalog = FakeCatalogRepository(); + await tester.pumpWidget( + LumaDramaApp( + playerFactory: createWidgetPlayer, + catalogRepository: catalog, + categoryRepository: FakeCategoryRepository(catalog: catalog), + watchHistoryRepository: InMemoryWatchHistoryRepository( + seed: const [ + WatchProgress( + dramaId: 'city-lights-demo', + episodeId: 'city-lights-demo-1', + position: Duration(minutes: 2), + duration: Duration(minutes: 5), + ), + ], + ), + favoriteRepository: InMemoryFavoriteRepository(cards: catalog.dramas), + playbackRepository: FakePlaybackRepository(), + commentRepository: FakeCommentRepository(), + interactionRepository: FakeInteractionRepository(), + noticeRepository: FakeNoticeRepository(), + haptics: FakeHaptics(), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('tab-my'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('mine-user-row')), findsOneWidget); + expect(find.byKey(const Key('mine-wallet')), findsOneWidget); + expect(find.byKey(const Key('mine-continue-watching')), findsOneWidget); + expect(find.byKey(const Key('mine-language')), findsOneWidget); + expect(find.byKey(const Key('mine-library-tab-0')), findsOneWidget); + expect(find.byKey(const Key('mine-library-tab-1')), findsOneWidget); + expect(find.byKey(const Key('mine-library-tab-2')), findsOneWidget); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/notice/notices_entry_test.dart b/test/notice/notices_entry_test.dart index db6d08b..1767c7f 100644 --- a/test/notice/notices_entry_test.dart +++ b/test/notice/notices_entry_test.dart @@ -46,6 +46,8 @@ void main() { await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('tab-my'))); await tester.pumpAndSettle(); + await tester.drag(find.byType(ListView), const Offset(0, -520)); + await tester.pumpAndSettle(); expect(find.byKey(const Key('mine-notices')), findsOneWidget); await tester.tap(find.byKey(const Key('mine-notices'))); diff --git a/test/watch/watch_history_provider_test.dart b/test/watch/watch_history_provider_test.dart index 932728f..2b10336 100644 --- a/test/watch/watch_history_provider_test.dart +++ b/test/watch/watch_history_provider_test.dart @@ -42,6 +42,8 @@ void main() { await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('tab-my'))); await tester.pumpAndSettle(); + await tester.drag(find.byType(ListView), const Offset(0, -520)); + await tester.pumpAndSettle(); await tester.tap(find.text('Watch History')); await tester.pumpAndSettle(); From 8767b5644b483684966976e8d327155632c98559 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 19 Oct 2026 15:50:24 +0800 Subject: [PATCH 4/4] =?UTF-8?q?test(ui):=20=E6=9B=B4=E6=96=B0=E4=B8=89=20T?= =?UTF-8?q?ab=20=E9=A2=84=E8=A7=88=E6=96=AD=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/home/home_nav_ui_test.dart | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/test/home/home_nav_ui_test.dart b/test/home/home_nav_ui_test.dart index 79dca09..14ceed5 100644 --- a/test/home/home_nav_ui_test.dart +++ b/test/home/home_nav_ui_test.dart @@ -52,15 +52,14 @@ void main() { final decoration = bar.decoration as BoxDecoration; final border = decoration.border as Border; expect(decoration.color, AppColors.bar.withValues(alpha: 0.95)); - expect(border.top.color, AppColors.border); - expect(border.top.color.a, lessThan(0.2)); + expect(border.top.color, AppColors.outlineVariantDark); expect(border.top.color, isNot(Colors.white)); expect( tester - .widget( + .widget( find.descendant( of: find.byKey(const Key('tab-home')), - matching: find.byType(AnimatedContainer), + matching: find.byType(AnimatedScale), ), ) .duration, @@ -69,7 +68,7 @@ void main() { expect( find.descendant( of: find.byKey(const Key('tab-home')), - matching: find.byIcon(Icons.home_rounded), + matching: find.byIcon(Icons.play_circle_fill_rounded), ), findsOneWidget, ); @@ -90,7 +89,7 @@ void main() { expect( find.descendant( of: find.byKey(const Key('tab-home')), - matching: find.byIcon(Icons.home_outlined), + matching: find.byIcon(Icons.play_circle_outline_rounded), ), findsOneWidget, ); @@ -100,7 +99,9 @@ void main() { tester, ) async { await _pumpApp(tester); - await tester.tap(find.byTooltip('Clear screen')); + await tester.tap(find.byKey(const Key('home-feed-more'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('home-feed-clear'))); await tester.pumpAndSettle(); for (final key in _keys) {