diff --git a/lib/core/data/models/drama.dart b/lib/core/data/models/drama.dart index 8b2f1b5..4f889ae 100644 --- a/lib/core/data/models/drama.dart +++ b/lib/core/data/models/drama.dart @@ -86,6 +86,8 @@ class Drama { this.favoriteCount, this.commentCount, this.language = '', + this.dramaType = '', + this.createdAt, this.detailLoaded = false, }); @@ -109,6 +111,12 @@ class Drama { /// 卡片语种(如 zh-CN / id),用于剧场语言筛选。 final String language; + /// 内容类型:ANIME 漫剧,LIVE 真人剧。 + final String dramaType; + + /// 上架时间;卡片接口未直接下发时可由 Snowflake id 估算。 + final DateTime? createdAt; + /// 详情接口是否已经取过完整剧集列表。 /// 列表卡片只带一集可播预览,需要完整剧集时再进详情。 final bool detailLoaded; @@ -137,6 +145,8 @@ class Drama { favoriteCount: _optionalInt(json['favoriteCount']), commentCount: _optionalInt(json['commentCount']), language: json['language'] as String? ?? '', + dramaType: json['dramaType'] as String? ?? '', + createdAt: DateTime.tryParse(json['createdAt']?.toString() ?? ''), ); } @@ -157,6 +167,8 @@ class Drama { favoriteCount: _optionalInt(json['favoriteCount']), commentCount: _optionalInt(json['commentCount']), language: json['language'] as String? ?? '', + dramaType: json['dramaType'] as String? ?? '', + createdAt: DateTime.tryParse(json['createdAt']?.toString() ?? ''), detailLoaded: true, episodes: (json['episodes'] as List? ?? const []) .whereType>() diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart index c8dcab2..83b62b3 100644 --- a/lib/core/theme/app_colors.dart +++ b/lib/core/theme/app_colors.dart @@ -60,6 +60,18 @@ abstract final class AppColors { /// 页面背景。 static const ink = Color(0xFF0B1020); + /// 首页/追剧/我的统一的纯黑内容背景。 + static const appBackground = Color(0xFF000000); + + /// 中性深灰卡片与控件表面。 + static const contentCard = Color(0xFF171717); + static const contentSurface = Color(0xFF1C1C1E); + static const contentChip = Color(0xFF2C2C2E); + static const contentBorder = Color(0x1AFFFFFF); + + /// 次级文字与列表 chevron。 + static const textSecondary = Color(0xFF8E8E93); + /// 原生开屏与冷启动过渡底色。 static const splashInk = ink; @@ -125,7 +137,12 @@ abstract final class AppColors { static const badgeBeige = raised; /// 导航未选中。 - static const navUnselected = Color(0xFF8E8E93); + static const navUnselected = textSecondary; + + /// 首页内容角标。 + static const badgeHot = Color(0xFFE5484D); + static const badgeNew = Color(0xFF3B82F6); + static const badgeAnime = Color(0xFF8B5CF6); /// 播放器次要文字。 static const playerMuted = Color(0xA3FFFFFF); diff --git a/lib/core/theme/app_dimens.dart b/lib/core/theme/app_dimens.dart index 299cb6f..f783c56 100644 --- a/lib/core/theme/app_dimens.dart +++ b/lib/core/theme/app_dimens.dart @@ -73,4 +73,18 @@ abstract final class AppDimens { static const double cardTitle = 15; static const double cardShadowBlur = 0; static const double cardShadowY = 0; + + /// 首页探索网格。 + static const double homeGridGap = 8; + static const double homeCardRadius = 8; + static const double homeCardPadding = 8; + static const double homeCardTitle = 13; + static const double homeCardMeta = 10; + static const double homeCardBadge = 9; + static const double homeTagFont = 10; + static const double homeCardBodyExtent = 84; + static const double homeSectionFont = 14; + static const double homeSectionIndicator = 2; + static const double homeIconSlot = 44; + static const double homeHeaderIcon = 22; } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 20336f1..b4899d1 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -697,5 +697,22 @@ "danmakuSettings": "Danmaku settings", "offlineCache": "Offline cache", "screenCast": "Cast", - "sleepTimer": "Sleep timer" + "sleepTimer": "Sleep timer", + "homeSearchHint": "Search dramas, actors, genres...", + "homeSectionFeatured": "Featured", + "homeSectionNew": "New", + "homeSectionCategories": "Categories", + "homeSectionRanking": "Charts", + "homeSectionCostume": "Costume", + "homeSectionCommunity": "Community", + "homeTagHot": "Hot", + "homeTagUplifting": "Thrilling", + "homeTagSweet": "Sweet", + "homeTagFaceSlap": "Revenge", + "homeTagAngst": "Angst", + "homeBadgeHot": "Hot", + "homeBadgeNew": "New", + "homeBadgeAnime": "Anime", + "homeGridFilterEmpty": "No dramas match these filters", + "homeGridClearFilter": "Clear filters" } diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 684a989..38bc11f 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -654,5 +654,22 @@ "danmakuSettings": "Pengaturan komentar layar", "offlineCache": "Cache offline", "screenCast": "Cast", - "sleepTimer": "Timer tidur" + "sleepTimer": "Timer tidur", + "homeSearchHint": "Cari drama, aktor, genre...", + "homeSectionFeatured": "Unggulan", + "homeSectionNew": "Baru", + "homeSectionCategories": "Kategori", + "homeSectionRanking": "Peringkat", + "homeSectionCostume": "Kostum", + "homeSectionCommunity": "Komunitas", + "homeTagHot": "Populer", + "homeTagUplifting": "Membara", + "homeTagSweet": "Manis", + "homeTagFaceSlap": "Balas Dendam", + "homeTagAngst": "Cinta Penuh Luka", + "homeBadgeHot": "Populer", + "homeBadgeNew": "Baru", + "homeBadgeAnime": "Anime", + "homeGridFilterEmpty": "Tidak ada drama yang cocok dengan filter", + "homeGridClearFilter": "Hapus filter" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 5ce83e2..776d1c0 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -2997,6 +2997,108 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Sleep timer'** String get sleepTimer; + + /// No description provided for @homeSearchHint. + /// + /// In en, this message translates to: + /// **'Search dramas, actors, genres...'** + String get homeSearchHint; + + /// No description provided for @homeSectionFeatured. + /// + /// In en, this message translates to: + /// **'Featured'** + String get homeSectionFeatured; + + /// No description provided for @homeSectionNew. + /// + /// In en, this message translates to: + /// **'New'** + String get homeSectionNew; + + /// No description provided for @homeSectionCategories. + /// + /// In en, this message translates to: + /// **'Categories'** + String get homeSectionCategories; + + /// No description provided for @homeSectionRanking. + /// + /// In en, this message translates to: + /// **'Charts'** + String get homeSectionRanking; + + /// No description provided for @homeSectionCostume. + /// + /// In en, this message translates to: + /// **'Costume'** + String get homeSectionCostume; + + /// No description provided for @homeSectionCommunity. + /// + /// In en, this message translates to: + /// **'Community'** + String get homeSectionCommunity; + + /// No description provided for @homeTagHot. + /// + /// In en, this message translates to: + /// **'Hot'** + String get homeTagHot; + + /// No description provided for @homeTagUplifting. + /// + /// In en, this message translates to: + /// **'Thrilling'** + String get homeTagUplifting; + + /// No description provided for @homeTagSweet. + /// + /// In en, this message translates to: + /// **'Sweet'** + String get homeTagSweet; + + /// No description provided for @homeTagFaceSlap. + /// + /// In en, this message translates to: + /// **'Revenge'** + String get homeTagFaceSlap; + + /// No description provided for @homeTagAngst. + /// + /// In en, this message translates to: + /// **'Angst'** + String get homeTagAngst; + + /// No description provided for @homeBadgeHot. + /// + /// In en, this message translates to: + /// **'Hot'** + String get homeBadgeHot; + + /// No description provided for @homeBadgeNew. + /// + /// In en, this message translates to: + /// **'New'** + String get homeBadgeNew; + + /// No description provided for @homeBadgeAnime. + /// + /// In en, this message translates to: + /// **'Anime'** + String get homeBadgeAnime; + + /// No description provided for @homeGridFilterEmpty. + /// + /// In en, this message translates to: + /// **'No dramas match these filters'** + String get homeGridFilterEmpty; + + /// No description provided for @homeGridClearFilter. + /// + /// In en, this message translates to: + /// **'Clear filters'** + String get homeGridClearFilter; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 36952cf..800a55f 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1565,4 +1565,55 @@ class AppLocalizationsEn extends AppLocalizations { @override String get sleepTimer => 'Sleep timer'; + + @override + String get homeSearchHint => 'Search dramas, actors, genres...'; + + @override + String get homeSectionFeatured => 'Featured'; + + @override + String get homeSectionNew => 'New'; + + @override + String get homeSectionCategories => 'Categories'; + + @override + String get homeSectionRanking => 'Charts'; + + @override + String get homeSectionCostume => 'Costume'; + + @override + String get homeSectionCommunity => 'Community'; + + @override + String get homeTagHot => 'Hot'; + + @override + String get homeTagUplifting => 'Thrilling'; + + @override + String get homeTagSweet => 'Sweet'; + + @override + String get homeTagFaceSlap => 'Revenge'; + + @override + String get homeTagAngst => 'Angst'; + + @override + String get homeBadgeHot => 'Hot'; + + @override + String get homeBadgeNew => 'New'; + + @override + String get homeBadgeAnime => 'Anime'; + + @override + String get homeGridFilterEmpty => 'No dramas match these filters'; + + @override + String get homeGridClearFilter => 'Clear filters'; } diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index a540d17..22e2caa 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -1565,4 +1565,55 @@ class AppLocalizationsId extends AppLocalizations { @override String get sleepTimer => 'Timer tidur'; + + @override + String get homeSearchHint => 'Cari drama, aktor, genre...'; + + @override + String get homeSectionFeatured => 'Unggulan'; + + @override + String get homeSectionNew => 'Baru'; + + @override + String get homeSectionCategories => 'Kategori'; + + @override + String get homeSectionRanking => 'Peringkat'; + + @override + String get homeSectionCostume => 'Kostum'; + + @override + String get homeSectionCommunity => 'Komunitas'; + + @override + String get homeTagHot => 'Populer'; + + @override + String get homeTagUplifting => 'Membara'; + + @override + String get homeTagSweet => 'Manis'; + + @override + String get homeTagFaceSlap => 'Balas Dendam'; + + @override + String get homeTagAngst => 'Cinta Penuh Luka'; + + @override + String get homeBadgeHot => 'Populer'; + + @override + String get homeBadgeNew => 'Baru'; + + @override + String get homeBadgeAnime => 'Anime'; + + @override + String get homeGridFilterEmpty => 'Tidak ada drama yang cocok dengan filter'; + + @override + String get homeGridClearFilter => 'Hapus filter'; } diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index d57351f..299c1ff 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -1541,4 +1541,55 @@ class AppLocalizationsZh extends AppLocalizations { @override String get sleepTimer => '定时关闭'; + + @override + String get homeSearchHint => '搜索短剧、演员、类型…'; + + @override + String get homeSectionFeatured => '精选'; + + @override + String get homeSectionNew => '新剧'; + + @override + String get homeSectionCategories => '分类'; + + @override + String get homeSectionRanking => '榜单'; + + @override + String get homeSectionCostume => '古装'; + + @override + String get homeSectionCommunity => '社群'; + + @override + String get homeTagHot => '热门'; + + @override + String get homeTagUplifting => '爽剧'; + + @override + String get homeTagSweet => '甜宠'; + + @override + String get homeTagFaceSlap => '打脸虐渣'; + + @override + String get homeTagAngst => '虐恋'; + + @override + String get homeBadgeHot => '热剧'; + + @override + String get homeBadgeNew => '新剧'; + + @override + String get homeBadgeAnime => '漫剧'; + + @override + String get homeGridFilterEmpty => '没有符合筛选条件的短剧'; + + @override + String get homeGridClearFilter => '清除筛选'; } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 25647d4..e8c2a0f 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -616,5 +616,22 @@ "danmakuSettings": "弹幕设置", "offlineCache": "离线缓存", "screenCast": "投屏", - "sleepTimer": "定时关闭" + "sleepTimer": "定时关闭", + "homeSearchHint": "搜索短剧、演员、类型…", + "homeSectionFeatured": "精选", + "homeSectionNew": "新剧", + "homeSectionCategories": "分类", + "homeSectionRanking": "榜单", + "homeSectionCostume": "古装", + "homeSectionCommunity": "社群", + "homeTagHot": "热门", + "homeTagUplifting": "爽剧", + "homeTagSweet": "甜宠", + "homeTagFaceSlap": "打脸虐渣", + "homeTagAngst": "虐恋", + "homeBadgeHot": "热剧", + "homeBadgeNew": "新剧", + "homeBadgeAnime": "漫剧", + "homeGridFilterEmpty": "没有符合筛选条件的短剧", + "homeGridClearFilter": "清除筛选" } diff --git a/lib/pages/home/home_view.dart b/lib/pages/home/home_view.dart index ec3eebe..d153f09 100644 --- a/lib/pages/home/home_view.dart +++ b/lib/pages/home/home_view.dart @@ -13,24 +13,32 @@ import '../../core/data/services/home_feed_controller.dart'; import '../../core/data/services/catalog_controller.dart'; import '../../core/data/services/catalog_retry.dart'; import '../../core/data/services/category_controller.dart'; +import '../../core/data/services/wallet_controller.dart'; import '../../core/platform/haptics.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_theme.dart'; +import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/app_root_tab.dart'; -import '../../core/widgets/catalog_debug_info.dart'; import '../../core/widgets/catalog_endpoint_editor.dart'; import '../../core/widgets/empty_state.dart'; import '../../l10n/app_localizations.dart'; -import '../discover/discover_types.dart'; import '../discover/discover_view.dart'; import '../favorites/favorites_page.dart'; +import '../filter/filter_page.dart'; import '../mine/mine_types.dart'; +import '../new_releases/new_releases_page.dart'; import '../player/widgets/home_feed_header.dart'; import '../player/player_view.dart'; +import '../ranking/ranking_page.dart'; import '../mine/mine_view.dart'; +import '../store/vip_page.dart'; +import '../wallet/wallet_page.dart'; import 'home_feed_channel.dart'; import 'home_binding.dart'; import 'home_logic.dart'; +import 'widgets/home_feed_grid.dart'; +import 'widgets/home_failure_view.dart'; +import 'widgets/home_grid_header.dart'; class HomePage extends StatefulWidget { const HomePage({super.key}); @@ -189,34 +197,6 @@ class _HomePageState extends State with WidgetsBindingObserver { } } -/// 首页目录加载失败与重试入口。 -class HomeFailureView extends StatelessWidget { - const HomeFailureView({ - super.key, - this.error, - required this.onRetry, - this.onEditEndpoint, - }); - - final String? error; - final VoidCallback onRetry; - final VoidCallback? onEditEndpoint; - - @override - Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context)!; - return EmptyState( - icon: Icons.cloud_off_rounded, - title: l10n.catalogError, - actionLabel: l10n.retry, - onAction: onRetry, - footer: kDebugMode - ? CatalogDebugInfo(error: error, onEditEndpoint: onEditEndpoint) - : null, - ); - } -} - /// 四个根页面共享同一路由并保持各自状态。 class _HomeTabs extends StatefulWidget { const _HomeTabs({super.key, required this.feedController}); @@ -261,7 +241,7 @@ class _HomeTabsState extends State<_HomeTabs> { } Widget _scaffold(BuildContext context) { - final feed = logic.tab == 0; + final mediaTab = logic.tab == 0 || logic.tab == 1; final content = PopScope( canPop: false, onPopInvokedWithResult: (didPop, _) { @@ -278,7 +258,7 @@ class _HomeTabsState extends State<_HomeTabs> { ); return AnnotatedRegion( value: SystemUiOverlayStyle.light, - child: feed + child: mediaTab ? content : DecoratedBox( key: const Key('home-page-gradient'), @@ -292,15 +272,31 @@ class _HomeTabsState extends State<_HomeTabs> { return IndexedStack( index: logic.tab, children: [ - _home(context), - _discover(context), + _homeGrid(context), + _recommend(context), const FavoritesContent(key: Key('favorites-page'), showBack: false), _my(context), ], ); } - Widget _home(BuildContext context) { + Widget _homeGrid(BuildContext context) { + return HomeFeedGrid( + feedController: widget.feedController, + callbacks: HomeGridCallbacks( + onSearch: () => _openSearch(context), + onOpenVip: () => unawaited(_openVip(context)), + onOpenWallet: () => unawaited(_openWallet(context)), + onOpenNewReleases: () => _pushPage(context, const NewReleasesPage()), + onOpenCategories: () => _pushPage(context, const FilterPage()), + onOpenRanking: () => _pushPage(context, const RankingPage()), + onOpenFilter: () => _pushPage(context, const FilterPage()), + ), + onOpenDrama: (drama) => unawaited(logic.playDrama(context, drama)), + ); + } + + Widget _recommend(BuildContext context) { final currentDramas = widget.feedController.dramas; final currentChannel = widget.feedController.channel; if (currentDramas.isNotEmpty) { @@ -329,7 +325,7 @@ class _HomeTabsState extends State<_HomeTabs> { PlayerPage( homeMode: true, embedded: true, - active: logic.tab == 0, + active: logic.tab == 1, drama: playerDramas.first, initialIndex: 0, feedDramas: playerDramas, @@ -342,7 +338,7 @@ class _HomeTabsState extends State<_HomeTabs> { onChannelSelected: _selectFeedChannel, onDramaChanged: logic.onDramaChanged, onImmersiveChanged: logic.onImmersiveChanged, - onSearch: () => logic.select(1, search: true), + onSearch: () => _openSearch(context), onDiscover: () => logic.select(1), onMy: () => logic.select(3), ), @@ -383,7 +379,7 @@ class _HomeTabsState extends State<_HomeTabs> { channel: widget.feedController.channel, onChannelSelected: _selectFeedChannel, onRefresh: () => _refreshPlaceholder(context), - onSearch: () => logic.select(1, search: true), + onSearch: () => _openSearch(context), onMore: () {}, ), Expanded( @@ -427,18 +423,33 @@ class _HomeTabsState extends State<_HomeTabs> { unawaited(widget.feedController.selectChannel(channel)); } - Widget _discover(BuildContext context) { - return DiscoverPage( - active: logic.tab == 1, - searchRequest: logic.searchRequest, - callbacks: DiscoverPageCallbacks( - onAction: logic.discoverAction, - onDramaSelected: (drama) => logic.playDrama(context, drama), - onProfile: logic.openProfile, + void _openSearch(BuildContext context) { + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const DiscoverPage(focusSearch: true)), + ); + } + + void _pushPage(BuildContext context, Widget page) { + Navigator.of(context).push(MaterialPageRoute(builder: (_) => page)); + } + + Future _openVip(BuildContext context) async { + if (!await ensureSignedIn(context) || !context.mounted) return; + final wallet = context.read(); + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => VipPage(onPurchased: () => unawaited(wallet.load())), ), ); } + Future _openWallet(BuildContext context) async { + if (!await ensureSignedIn(context) || !context.mounted) return; + await Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => const WalletPage())); + } + Widget _my(BuildContext context) { return MyPage( active: logic.tab == 3, diff --git a/lib/pages/home/widgets/home_drama_classification.dart b/lib/pages/home/widgets/home_drama_classification.dart new file mode 100644 index 0000000..88e87d6 --- /dev/null +++ b/lib/pages/home/widgets/home_drama_classification.dart @@ -0,0 +1,49 @@ +// Author: fruit.com.cn + +import '../../../core/data/models/drama.dart'; + +const int homeNewReleaseDays = 7; +const int homeHotViewThreshold = 100000; +const int _snowflakeEpochMillis = 1288834974657; +const int _snowflakeTimestampShift = 22; + +const Map> _tagAliases = { + 'uplifting': ['爽剧', '逆袭', '复仇', 'revenge', 'balas'], + 'sweet': ['甜宠', '甜', 'sweet', 'romantis'], + 'faceSlap': ['打脸', '虐渣', 'comeuppance', 'comeback'], + 'angst': ['虐恋', '虐心', 'angst', 'heartbreak'], +}; + +DateTime? publishedAtForDrama(Drama drama) { + if (drama.createdAt != null) return drama.createdAt; + final snowflake = BigInt.tryParse(drama.id); + if (snowflake == null) return null; + final timestamp = + (snowflake >> _snowflakeTimestampShift).toInt() + _snowflakeEpochMillis; + final publishedAt = DateTime.fromMillisecondsSinceEpoch(timestamp); + return publishedAt.year >= 2015 ? publishedAt : null; +} + +bool isNewDrama(Drama drama, {DateTime? now}) { + final publishedAt = publishedAtForDrama(drama); + if (publishedAt == null) return false; + final age = (now ?? DateTime.now()).difference(publishedAt); + return !age.isNegative && age <= const Duration(days: homeNewReleaseDays); +} + +bool isHotDrama(Drama drama) { + return drama.isHot || drama.viewCount >= homeHotViewThreshold; +} + +bool matchesHomeTag(Drama drama, String tagId) { + if (tagId == 'hot') return isHotDrama(drama); + return tagMatchesText(tagId, drama.genre) || + tagMatchesText(tagId, drama.category); +} + +bool tagMatchesText(String tagId, String text) { + if (tagId == 'hot') return false; + final normalized = text.toLowerCase(); + final aliases = _tagAliases[tagId] ?? const []; + return aliases.any(normalized.contains); +} diff --git a/lib/pages/home/widgets/home_failure_view.dart b/lib/pages/home/widgets/home_failure_view.dart new file mode 100644 index 0000000..8ec39d8 --- /dev/null +++ b/lib/pages/home/widgets/home_failure_view.dart @@ -0,0 +1,36 @@ +// Author: fruit.com.cn + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import '../../../core/widgets/catalog_debug_info.dart'; +import '../../../core/widgets/empty_state.dart'; +import '../../../l10n/app_localizations.dart'; + +/// 首页目录加载失败与重试入口。 +class HomeFailureView extends StatelessWidget { + const HomeFailureView({ + super.key, + this.error, + required this.onRetry, + this.onEditEndpoint, + }); + + final String? error; + final VoidCallback onRetry; + final VoidCallback? onEditEndpoint; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return EmptyState( + icon: Icons.cloud_off_rounded, + title: l10n.catalogError, + actionLabel: l10n.retry, + onAction: onRetry, + footer: kDebugMode + ? CatalogDebugInfo(error: error, onEditEndpoint: onEditEndpoint) + : null, + ); + } +} diff --git a/lib/pages/home/widgets/home_feed_card.dart b/lib/pages/home/widgets/home_feed_card.dart new file mode 100644 index 0000000..9b3870d --- /dev/null +++ b/lib/pages/home/widgets/home_feed_card.dart @@ -0,0 +1,225 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../../../core/data/models/drama.dart'; +import '../../../core/formatting/count_format.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_dimens.dart'; +import '../../../core/widgets/remote_image.dart'; +import '../../../l10n/app_localizations.dart'; +import 'home_drama_classification.dart'; + +const double _coverAspectRatio = 5 / 6; +final RegExp _tagSplitter = RegExp(r'[,/、·]'); + +enum HomeDramaBadge { hot, newRelease, anime } + +class HomeFeedCard extends StatelessWidget { + const HomeFeedCard({ + super.key, + required this.drama, + required this.onTap, + this.now, + }); + + final Drama drama; + final VoidCallback onTap; + final DateTime? now; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return Container( + key: Key('home-feed-card-${drama.id}'), + decoration: BoxDecoration( + color: AppColors.contentCard, + borderRadius: BorderRadius.circular(AppDimens.homeCardRadius), + border: Border.all(color: AppColors.contentBorder), + ), + child: Material( + color: AppColors.transparent, + borderRadius: BorderRadius.circular(AppDimens.homeCardRadius), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _cover(l10n), + Expanded( + child: Padding( + padding: const EdgeInsets.all(AppDimens.homeCardPadding), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + drama.title, + key: Key('home-feed-title-${drama.id}'), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.navSelected, + fontSize: AppDimens.homeCardTitle, + fontWeight: FontWeight.w600, + height: 1.25, + ), + ), + const SizedBox(height: AppDimens.spaceXs), + _tags(), + ], + ), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _cover(AppLocalizations l10n) { + final badge = _badge(); + return AspectRatio( + aspectRatio: _coverAspectRatio, + child: Stack( + fit: StackFit.expand, + children: [ + RemoteImage( + drama.posterUrl, + key: Key('home-feed-cover-${drama.id}'), + fit: BoxFit.cover, + showFallbackIcon: true, + fallbackColor: AppColors.contentSurface, + ), + if (badge != null) + Positioned( + top: AppDimens.spaceXs, + left: AppDimens.spaceXs, + child: _badgeChip(l10n, badge), + ), + Positioned( + left: AppDimens.spaceXs, + bottom: AppDimens.spaceXs, + child: _views(), + ), + ], + ), + ); + } + + HomeDramaBadge? _badge() { + if (drama.dramaType.toUpperCase() == 'ANIME') { + return HomeDramaBadge.anime; + } + if (isNewDrama(drama, now: now)) return HomeDramaBadge.newRelease; + if (isHotDrama(drama)) { + return HomeDramaBadge.hot; + } + return null; + } + + Widget _badgeChip(AppLocalizations l10n, HomeDramaBadge badge) { + final (label, color) = switch (badge) { + HomeDramaBadge.hot => (l10n.homeBadgeHot, AppColors.badgeHot), + HomeDramaBadge.newRelease => (l10n.homeBadgeNew, AppColors.badgeNew), + HomeDramaBadge.anime => (l10n.homeBadgeAnime, AppColors.badgeAnime), + }; + return Container( + key: Key('home-feed-badge-${badge.name}-${drama.id}'), + padding: const EdgeInsets.symmetric( + horizontal: AppDimens.spaceSm, + vertical: 2, + ), + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(AppDimens.chipRadius), + ), + child: Text( + label, + style: const TextStyle( + color: AppColors.navSelected, + fontSize: AppDimens.homeCardBadge, + fontWeight: FontWeight.w700, + letterSpacing: 0, + ), + ), + ); + } + + Widget _views() { + return Container( + key: Key('home-feed-views-${drama.id}'), + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), + decoration: BoxDecoration( + color: AppColors.playerOverlayPill, + borderRadius: BorderRadius.circular(AppDimens.pillRadius), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.play_arrow_rounded, + size: AppDimens.homeCardMeta, + color: AppColors.navSelected, + ), + const SizedBox(width: 1), + Text( + formatInteractionCount(drama.viewCount), + style: const TextStyle( + color: AppColors.navSelected, + fontSize: AppDimens.homeCardMeta, + fontWeight: FontWeight.w600, + letterSpacing: 0, + ), + ), + ], + ), + ); + } + + Widget _tags() { + final tags = _cardTags(); + if (tags.isEmpty) return const SizedBox.shrink(); + return Row( + children: [ + for (final (index, tag) in tags.indexed) + Flexible( + child: Padding( + padding: EdgeInsets.only( + right: index == tags.length - 1 ? 0 : AppDimens.spaceXs, + ), + child: Container( + key: Key('home-feed-tag-${drama.id}-$index'), + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), + decoration: BoxDecoration( + color: AppColors.contentSurface, + borderRadius: BorderRadius.circular(AppDimens.chipRadius), + ), + child: Text( + tag, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: AppDimens.homeTagFont, + letterSpacing: 0, + ), + ), + ), + ), + ), + ], + ); + } + + List _cardTags() { + final seen = {}; + return [drama.genre, drama.category] + .expand((value) => value.split(_tagSplitter)) + .map((value) => value.trim()) + .where((value) => value.isNotEmpty && seen.add(value)) + .take(2) + .toList(growable: false); + } +} diff --git a/lib/pages/home/widgets/home_feed_grid.dart b/lib/pages/home/widgets/home_feed_grid.dart new file mode 100644 index 0000000..cd76a56 --- /dev/null +++ b/lib/pages/home/widgets/home_feed_grid.dart @@ -0,0 +1,294 @@ +// Author: fruit.com.cn + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../../core/data/models/content_category.dart'; +import '../../../core/data/models/drama.dart'; +import '../../../core/data/services/auth_controller.dart'; +import '../../../core/data/services/category_controller.dart'; +import '../../../core/data/services/home_feed_controller.dart'; +import '../../../core/data/services/wallet_controller.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_dimens.dart'; +import '../../../core/widgets/app_pull_to_refresh.dart'; +import '../../../core/widgets/empty_state.dart'; +import '../../../core/widgets/paged_list_footer.dart'; +import '../../../l10n/app_localizations.dart'; +import 'home_drama_classification.dart'; +import 'home_feed_card.dart'; +import 'home_grid_header.dart'; + +const double _loadMoreThreshold = 240; +const List _costumeAliases = ['古装', 'costume', 'period']; +const List _communityAliases = ['社群', 'community', 'komunitas']; + +class HomeFeedGrid extends StatefulWidget { + const HomeFeedGrid({ + super.key, + required this.feedController, + required this.callbacks, + required this.onOpenDrama, + }); + + final HomeFeedController feedController; + final HomeGridCallbacks callbacks; + final ValueChanged onOpenDrama; + + @override + State createState() => _HomeFeedGridState(); +} + +class _HomeFeedGridState extends State { + String? _selectedCategory; + String? _selectedTag; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final categories = context.watch().categories; + final auth = context.watch(); + final wallet = context.watch(); + final shortcuts = _categoryShortcuts(categories, l10n); + final tags = _availableTags(l10n); + final activeCategory = + shortcuts.any((shortcut) => shortcut.categoryName == _selectedCategory) + ? _selectedCategory + : null; + final activeTag = tags.any((tag) => tag.id == _selectedTag) + ? _selectedTag + : null; + final dramas = _filteredDramas(activeCategory, activeTag); + final walletBadge = auth.isSignedIn && wallet.snapshot != null + ? wallet.balance + : null; + return ColoredBox( + key: const Key('home-grid'), + color: AppColors.appBackground, + child: SafeArea( + bottom: false, + child: LayoutBuilder( + builder: (context, constraints) { + final itemWidth = + (constraints.maxWidth - + AppDimens.pageHorizontal * 2 - + AppDimens.homeGridGap * 2) / + 3; + final itemExtent = itemWidth * 6 / 5 + AppDimens.homeCardBodyExtent; + return NotificationListener( + onNotification: _onScroll, + child: AppPullToRefresh( + key: const Key('home-grid-refresh'), + onRefresh: _refresh, + child: CustomScrollView( + key: const Key('home-grid-scroll'), + physics: AppPullToRefresh.physics, + slivers: [ + SliverToBoxAdapter( + child: HomeGridHeader( + callbacks: widget.callbacks, + categories: shortcuts, + tags: tags, + selectedCategory: activeCategory, + selectedTag: activeTag, + onCategorySelected: _selectCategory, + onTagSelected: (tag) => + setState(() => _selectedTag = tag), + walletBadge: walletBadge, + ), + ), + if (widget.feedController.loadingFirstPage) + const SliverFillRemaining( + hasScrollBody: false, + child: Center( + child: SizedBox.square( + dimension: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ) + else if (dramas.isEmpty) + SliverFillRemaining( + hasScrollBody: false, + child: _emptyState(l10n, activeCategory, activeTag), + ) + else + SliverPadding( + padding: const EdgeInsets.symmetric( + horizontal: AppDimens.pageHorizontal, + ), + sliver: SliverGrid( + key: const Key('home-grid-cards'), + gridDelegate: + SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + crossAxisSpacing: AppDimens.homeGridGap, + mainAxisSpacing: AppDimens.homeGridGap, + mainAxisExtent: itemExtent, + ), + delegate: SliverChildBuilderDelegate( + (context, index) => HomeFeedCard( + drama: dramas[index], + onTap: () => widget.onOpenDrama(dramas[index]), + ), + childCount: dramas.length, + ), + ), + ), + if (dramas.isNotEmpty) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.only( + bottom: AppDimens.spaceXl, + ), + child: PagedListFooter( + loading: widget.feedController.loadingMore, + hasMore: widget.feedController.hasMore, + hasItems: dramas.isNotEmpty, + errorMessage: + widget.feedController.lastError == null + ? null + : l10n.catalogError, + onRetry: widget.feedController.loadMore, + ), + ), + ), + ], + ), + ), + ); + }, + ), + ), + ); + } + + Future _refresh() async { + await widget.feedController.refreshCurrent(); + } + + bool _onScroll(ScrollNotification notification) { + if (notification.depth != 0) return false; + final reachedBottom = + notification.metrics.extentAfter <= _loadMoreThreshold; + if (reachedBottom && + (notification is ScrollUpdateNotification || + notification is ScrollEndNotification || + notification is OverscrollNotification)) { + unawaited(widget.feedController.loadMore()); + } + return false; + } + + void _selectCategory(String? category) { + setState(() { + _selectedCategory = category; + _selectedTag = null; + }); + } + + List _categoryShortcuts( + List categories, + AppLocalizations l10n, + ) { + final shortcuts = []; + final costume = _findCategory(categories, _costumeAliases); + final community = _findCategory(categories, _communityAliases); + if (costume != null) { + shortcuts.add( + HomeGridCategoryShortcut( + label: l10n.homeSectionCostume, + categoryName: costume.name, + ), + ); + } + if (community != null) { + shortcuts.add( + HomeGridCategoryShortcut( + label: l10n.homeSectionCommunity, + categoryName: community.name, + ), + ); + } + return shortcuts; + } + + ContentCategory? _findCategory( + List categories, + List aliases, + ) { + for (final category in categories) { + final name = category.name.toLowerCase(); + if (aliases.any(name.contains)) return category; + } + return null; + } + + List _availableTags(AppLocalizations l10n) { + final definitions = [ + HomeGridTagOption(id: 'hot', label: l10n.homeTagHot), + HomeGridTagOption(id: 'uplifting', label: l10n.homeTagUplifting), + HomeGridTagOption(id: 'sweet', label: l10n.homeTagSweet), + HomeGridTagOption(id: 'faceSlap', label: l10n.homeTagFaceSlap), + HomeGridTagOption(id: 'angst', label: l10n.homeTagAngst), + ]; + final categoryText = context + .read() + .categories + .map((category) => category.name) + .toList(growable: false); + return definitions + .where((option) { + if (option.id == 'hot') { + return widget.feedController.dramas.any(isHotDrama); + } + return categoryText.any((text) => tagMatchesText(option.id, text)) || + widget.feedController.dramas.any( + (drama) => matchesHomeTag(drama, option.id), + ); + }) + .toList(growable: false); + } + + List _filteredDramas(String? category, String? tag) { + final normalizedCategory = category?.toLowerCase(); + return widget.feedController.dramas + .where((drama) { + final categoryMatches = + normalizedCategory == null || + drama.category.toLowerCase().contains(normalizedCategory) || + drama.genre.toLowerCase().contains(normalizedCategory); + final tagMatches = tag == null || matchesHomeTag(drama, tag); + return categoryMatches && tagMatches; + }) + .toList(growable: false); + } + + Widget _emptyState(AppLocalizations l10n, String? category, String? tag) { + if (widget.feedController.lastError != null) { + return EmptyState( + key: const Key('home-grid-error'), + icon: Icons.cloud_off_rounded, + title: l10n.catalogError, + actionLabel: l10n.retry, + onAction: () => unawaited(widget.feedController.refreshCurrent()), + ); + } + final filtered = category != null || tag != null; + return EmptyState( + key: const Key('home-grid-empty'), + icon: Icons.movie_filter_outlined, + title: filtered ? l10n.homeGridFilterEmpty : l10n.noStories, + subtitle: filtered ? null : l10n.emptyHint, + actionLabel: filtered ? l10n.homeGridClearFilter : null, + onAction: filtered + ? () => setState(() { + _selectedCategory = null; + _selectedTag = null; + }) + : null, + ); + } +} diff --git a/lib/pages/home/widgets/home_grid_header.dart b/lib/pages/home/widgets/home_grid_header.dart new file mode 100644 index 0000000..830330c --- /dev/null +++ b/lib/pages/home/widgets/home_grid_header.dart @@ -0,0 +1,365 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../../../core/formatting/count_format.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_dimens.dart'; +import '../../../l10n/app_localizations.dart'; + +class HomeGridCategoryShortcut { + const HomeGridCategoryShortcut({ + required this.label, + required this.categoryName, + }); + + final String label; + final String categoryName; +} + +class HomeGridTagOption { + const HomeGridTagOption({required this.id, required this.label}); + + final String id; + final String label; +} + +class HomeGridCallbacks { + const HomeGridCallbacks({ + required this.onSearch, + required this.onOpenVip, + required this.onOpenWallet, + required this.onOpenNewReleases, + required this.onOpenCategories, + required this.onOpenRanking, + required this.onOpenFilter, + }); + + final VoidCallback onSearch; + final VoidCallback onOpenVip; + final VoidCallback onOpenWallet; + final VoidCallback onOpenNewReleases; + final VoidCallback onOpenCategories; + final VoidCallback onOpenRanking; + final VoidCallback onOpenFilter; +} + +class HomeGridHeader extends StatelessWidget { + const HomeGridHeader({ + super.key, + required this.callbacks, + required this.categories, + required this.tags, + required this.selectedCategory, + required this.selectedTag, + required this.onCategorySelected, + required this.onTagSelected, + this.walletBadge, + }); + + final HomeGridCallbacks callbacks; + final List categories; + final List tags; + final String? selectedCategory; + final String? selectedTag; + final ValueChanged onCategorySelected; + final ValueChanged onTagSelected; + final int? walletBadge; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return Column( + key: const Key('home-grid-header'), + children: [_searchRow(l10n), _primaryRow(l10n), _tagRow(l10n)], + ); + } + + Widget _searchRow(AppLocalizations l10n) { + return Padding( + padding: const EdgeInsets.fromLTRB( + AppDimens.pageHorizontal, + AppDimens.spaceSm, + AppDimens.pageHorizontal, + AppDimens.spaceSm, + ), + child: Row( + children: [ + Expanded( + child: InkWell( + key: const Key('home-search-entry'), + borderRadius: BorderRadius.circular(AppDimens.inputRadius), + onTap: callbacks.onSearch, + child: Container( + height: AppDimens.searchHeight, + padding: const EdgeInsets.symmetric( + horizontal: AppDimens.spaceMd, + ), + decoration: BoxDecoration( + color: AppColors.contentSurface, + borderRadius: BorderRadius.circular(AppDimens.inputRadius), + ), + child: Row( + children: [ + const Icon( + Icons.search_rounded, + size: AppDimens.homeHeaderIcon, + color: AppColors.textSecondary, + ), + const SizedBox(width: AppDimens.spaceSm), + Expanded( + child: Text( + l10n.homeSearchHint, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: AppDimens.body, + ), + ), + ), + ], + ), + ), + ), + ), + const SizedBox(width: AppDimens.spaceSm), + _headerAction( + key: const Key('home-vip-entry'), + icon: Icons.workspace_premium_outlined, + tooltip: l10n.vipMembership, + onTap: callbacks.onOpenVip, + ), + const SizedBox(width: AppDimens.spaceXs), + _headerAction( + key: const Key('home-wallet-entry'), + icon: Icons.card_giftcard_rounded, + tooltip: l10n.wallet, + onTap: callbacks.onOpenWallet, + badge: walletBadge, + ), + ], + ), + ); + } + + Widget _headerAction({ + required Key key, + required IconData icon, + required String tooltip, + required VoidCallback onTap, + int? badge, + }) { + return SizedBox.square( + dimension: AppDimens.homeIconSlot, + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned.fill( + child: Tooltip( + message: tooltip, + child: Material( + color: AppColors.contentSurface, + borderRadius: BorderRadius.circular(AppDimens.inputRadius), + child: InkWell( + key: key, + borderRadius: BorderRadius.circular(AppDimens.inputRadius), + onTap: onTap, + child: Icon( + icon, + size: AppDimens.homeHeaderIcon, + color: AppColors.navSelected, + ), + ), + ), + ), + ), + if (badge != null && badge > 0) + Positioned( + key: const Key('home-wallet-badge'), + top: -5, + right: -6, + child: Container( + constraints: const BoxConstraints(minWidth: 20, minHeight: 16), + padding: const EdgeInsets.symmetric(horizontal: 5), + alignment: Alignment.center, + decoration: BoxDecoration( + color: AppColors.badgeHot, + borderRadius: BorderRadius.circular(AppDimens.pillRadius), + ), + child: Text( + formatInteractionCount(badge), + style: const TextStyle( + color: AppColors.navSelected, + fontSize: AppDimens.homeCardBadge, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ], + ), + ); + } + + Widget _primaryRow(AppLocalizations l10n) { + return SingleChildScrollView( + key: const Key('home-primary-sections'), + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: AppDimens.pageHorizontal), + child: Row( + children: [ + _primaryItem( + key: const Key('home-section-featured'), + label: l10n.homeSectionFeatured, + selected: selectedCategory == null, + onTap: () => onCategorySelected(null), + ), + _primaryItem( + key: const Key('home-section-new'), + label: l10n.homeSectionNew, + selected: false, + onTap: callbacks.onOpenNewReleases, + ), + _primaryItem( + key: const Key('home-section-categories'), + label: l10n.homeSectionCategories, + selected: false, + onTap: callbacks.onOpenCategories, + ), + _primaryItem( + key: const Key('home-section-ranking'), + label: l10n.homeSectionRanking, + selected: false, + onTap: callbacks.onOpenRanking, + ), + for (final category in categories) + _primaryItem( + key: Key('home-section-category-${category.categoryName}'), + label: category.label, + selected: selectedCategory == category.categoryName, + onTap: () => onCategorySelected(category.categoryName), + ), + ], + ), + ); + } + + Widget _primaryItem({ + required Key key, + required String label, + required bool selected, + required VoidCallback onTap, + }) { + return InkWell( + key: key, + onTap: onTap, + child: Padding( + padding: const EdgeInsets.only(right: AppDimens.spaceXl), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + label, + style: TextStyle( + color: selected + ? AppColors.navSelected + : AppColors.textSecondary, + fontSize: AppDimens.homeSectionFont, + fontWeight: selected ? FontWeight.w700 : FontWeight.w500, + letterSpacing: 0, + ), + ), + const SizedBox(height: AppDimens.spaceXs), + Container( + height: AppDimens.homeSectionIndicator, + width: selected ? 20 : 0, + decoration: BoxDecoration( + color: AppColors.navSelected, + borderRadius: BorderRadius.circular(AppDimens.pillRadius), + ), + ), + ], + ), + ), + ); + } + + Widget _tagRow(AppLocalizations l10n) { + return Padding( + padding: const EdgeInsets.fromLTRB( + AppDimens.pageHorizontal, + AppDimens.spaceMd, + AppDimens.pageHorizontal, + AppDimens.spaceSm, + ), + child: Row( + children: [ + Expanded( + child: SingleChildScrollView( + key: const Key('home-secondary-tags'), + scrollDirection: Axis.horizontal, + child: Row( + children: [ + for (final tag in tags) ...[ + _tagChip(tag), + const SizedBox(width: AppDimens.spaceSm), + ], + ], + ), + ), + ), + const SizedBox(width: AppDimens.spaceXs), + Tooltip( + message: l10n.filter, + child: SizedBox.square( + dimension: AppDimens.tapTarget, + child: Material( + color: AppColors.contentSurface, + borderRadius: BorderRadius.circular(AppDimens.inputRadius), + child: InkWell( + key: const Key('home-grid-filter'), + borderRadius: BorderRadius.circular(AppDimens.inputRadius), + onTap: callbacks.onOpenFilter, + child: const Icon( + Icons.filter_list_rounded, + color: AppColors.navSelected, + size: AppDimens.homeHeaderIcon, + ), + ), + ), + ), + ), + ], + ), + ); + } + + Widget _tagChip(HomeGridTagOption tag) { + final selected = selectedTag == tag.id; + return InkWell( + key: Key('home-tag-${tag.id}'), + borderRadius: BorderRadius.circular(AppDimens.pillRadius), + onTap: () => onTagSelected(selected ? null : tag.id), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: AppDimens.spaceMd, + vertical: AppDimens.spaceSm, + ), + decoration: BoxDecoration( + color: selected ? AppColors.contentChip : AppColors.contentSurface, + borderRadius: BorderRadius.circular(AppDimens.pillRadius), + ), + child: Text( + tag.label, + style: TextStyle( + color: selected ? AppColors.navSelected : AppColors.textSecondary, + fontSize: AppDimens.homeSectionFont - 2, + fontWeight: selected ? FontWeight.w600 : FontWeight.w500, + letterSpacing: 0, + ), + ), + ), + ); + } +} diff --git a/test/discover/filter_page_test.dart b/test/discover/filter_page_test.dart index 91d82dc..061df58 100644 --- a/test/discover/filter_page_test.dart +++ b/test/discover/filter_page_test.dart @@ -151,7 +151,7 @@ Future _pumpFilterPage(WidgetTester tester) async { ), ); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('tab-recommend'))); + await tester.tap(find.byKey(const Key('home-search-entry'))); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('theater-toolbar-filter'))); await tester.pumpAndSettle(); diff --git a/test/discover/home_all_dramas_grid_test.dart b/test/discover/home_all_dramas_grid_test.dart index 5ed4eb0..697b52e 100644 --- a/test/discover/home_all_dramas_grid_test.dart +++ b/test/discover/home_all_dramas_grid_test.dart @@ -455,7 +455,9 @@ Future _pumpTheater( ), ); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('tab-recommend'))); + await tester.tap(find.byKey(const Key('home-search-entry'))); + await tester.pumpAndSettle(); + FocusManager.instance.primaryFocus?.unfocus(); await tester.pumpAndSettle(); } diff --git a/test/discover/theater_pages_test.dart b/test/discover/theater_pages_test.dart index 1e1877c..30d89ee 100644 --- a/test/discover/theater_pages_test.dart +++ b/test/discover/theater_pages_test.dart @@ -303,7 +303,7 @@ Future _pumpTheater( ), ); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('tab-recommend'))); + await tester.tap(find.byKey(const Key('home-search-entry'))); await tester.pumpAndSettle(); } diff --git a/test/home/catalog_diagnostics_test.dart b/test/home/catalog_diagnostics_test.dart index 4c6aefc..80be4fa 100644 --- a/test/home/catalog_diagnostics_test.dart +++ b/test/home/catalog_diagnostics_test.dart @@ -10,7 +10,7 @@ import 'package:fruitshorts/core/data/services/catalog_retry.dart'; import 'package:fruitshorts/core/theme/app_colors.dart'; import 'package:fruitshorts/core/widgets/catalog_endpoint_editor.dart'; import 'package:fruitshorts/l10n/app_localizations.dart'; -import 'package:fruitshorts/pages/home/home_view.dart'; +import 'package:fruitshorts/pages/home/widgets/home_failure_view.dart'; void main() { tearDown(AppConfig.clearRuntimeBaseUrl); diff --git a/test/home/home_card_style_test.dart b/test/home/home_card_style_test.dart index c205ebd..3744549 100644 --- a/test/home/home_card_style_test.dart +++ b/test/home/home_card_style_test.dart @@ -7,91 +7,157 @@ import 'package:fruitshorts/core/theme/app_colors.dart'; import 'package:fruitshorts/core/theme/app_dimens.dart'; import 'package:fruitshorts/core/theme/app_theme.dart'; import 'package:fruitshorts/l10n/app_localizations.dart'; -import 'package:fruitshorts/pages/home/widgets/home_drama_grid.dart'; +import 'package:fruitshorts/pages/home/widgets/home_feed_card.dart'; void main() { - testWidgets('should_render_compact_cover_badges_without_plus_action', ( + testWidgets('should_render_grid_card_cover_badge_views_title_and_tags', ( tester, ) async { + var taps = 0; + await _pumpCard( + tester, + drama: _drama( + id: 'anime-card', + title: 'A very long home grid drama title that must wrap', + dramaType: 'ANIME', + viewCount: 444500, + ), + onTap: () => taps++, + ); + + final card = find.byKey(const Key('home-feed-card-anime-card')); + final container = tester.widget(card); + final decoration = container.decoration! as BoxDecoration; + expect(decoration.color, AppColors.contentCard); + expect(decoration.boxShadow, isNull); + expect( + decoration.borderRadius, + BorderRadius.circular(AppDimens.homeCardRadius), + ); + expect((decoration.border! as Border).top.width, 1); + expect((decoration.border! as Border).top.color, AppColors.contentBorder); + + final cover = find.byKey(const Key('home-feed-cover-anime-card')); + final coverSize = tester.getSize(cover); + expect(coverSize.height / coverSize.width, closeTo(6 / 5, 0.02)); + expect( + find.byKey(const Key('home-feed-badge-anime-anime-card')), + findsOneWidget, + ); + expect(find.text('Anime'), findsOneWidget); + expect(find.byKey(const Key('home-feed-views-anime-card')), findsOneWidget); + final views = find.byKey(const Key('home-feed-views-anime-card')); + final viewsText = tester.widget( + find.descendant(of: views, matching: find.byType(Text)), + ); + expect(viewsText.data, startsWith('44.')); + expect( + tester + .widget(find.byKey(const Key('home-feed-title-anime-card'))) + .maxLines, + 2, + ); + expect(find.byKey(const Key('home-feed-tag-anime-card-0')), findsOneWidget); + expect(find.byKey(const Key('home-feed-tag-anime-card-1')), findsOneWidget); + expect(find.text('Romance'), findsOneWidget); + expect(find.text('Urban'), findsOneWidget); + + await tester.tap(card); + expect(taps, 1); + }); + + testWidgets('should_choose_anime_new_and_hot_badges_from_real_data', ( + tester, + ) async { + final now = DateTime(2026, 10, 22, 12); await tester.pumpWidget( MaterialApp( - theme: AppTheme.light(), + theme: AppTheme.dark(), locale: const Locale('en'), supportedLocales: AppLocalizations.supportedLocales, localizationsDelegates: AppLocalizations.localizationsDelegates, home: Scaffold( - body: SingleChildScrollView( - child: Builder( - builder: (context) => SizedBox( - width: 180, - child: HomeDramaCard( - l10n: AppLocalizations.of(context)!, - drama: _drama, - onTap: () {}, + body: Row( + children: [ + for (final drama in [ + _drama(id: 'anime', title: 'Anime', dramaType: 'ANIME'), + _drama( + id: 'new', + title: 'New', + createdAt: now.subtract(const Duration(days: 2)), ), - ), - ), + _drama(id: 'hot', title: 'Hot', isHot: true), + ]) + SizedBox( + width: 130, + height: 240, + child: HomeFeedCard(drama: drama, onTap: () {}, now: now), + ), + ], ), ), ), ); await tester.pumpAndSettle(); - final card = find.byKey(const Key('home-drama-card-style-drama')); - final cardContainer = tester.widget( - find.ancestor(of: card, matching: find.byType(Container)).last, - ); - final cardDecoration = cardContainer.decoration! as BoxDecoration; - expect(cardDecoration.color, AppColors.card); expect( - cardDecoration.borderRadius, - BorderRadius.circular(AppDimens.cardRadius), - ); - expect(cardDecoration.boxShadow, const [AppTheme.cardShadow]); - - final tag = tester.widget( - find.byKey(const Key('home-drama-tag-style-drama')), - ); - expect((tag.decoration! as BoxDecoration).color, AppColors.badgePink); - expect(find.text('Free'), findsOneWidget); - expect(find.text('All 24'), findsOneWidget); - expect( - find.byKey(const Key('home-drama-locked-style-drama')), - findsNothing, - ); - expect(find.byKey(const Key('home-drama-add-style-drama')), findsNothing); - - // 播放次数角标落在封面内,并按万位格式化。 - final views = find.byKey(const Key('home-drama-views-style-drama')); - expect( - find.descendant( - of: find.byKey(const Key('home-drama-cover-style-drama')), - matching: views, - ), + find.byKey(const Key('home-feed-badge-anime-anime')), findsOneWidget, ); expect( - find.descendant(of: views, matching: find.text('1.2万')), + find.byKey(const Key('home-feed-badge-newRelease-new')), findsOneWidget, ); - - // 压缩后卡片高度接近「卡宽 × 1.5」,明显矮于旧版 3:4 封面。 - final cardSize = tester.getSize(card); - expect(cardSize.height, lessThan(cardSize.width * 1.6)); - final coverSize = tester.getSize( - find.byKey(const Key('home-drama-cover-style-drama')), - ); - expect(coverSize.height / coverSize.width, closeTo(6 / 5, 0.02)); + expect(find.byKey(const Key('home-feed-badge-hot-hot')), findsOneWidget); }); } -const _drama = Drama( - id: 'style-drama', - title: 'Warm Orange Story', - synopsis: '暖橙短剧', - posterUrl: '/files/style.jpg', - genre: 'Romance', - totalEpisodes: 24, - freeEpisodes: 6, - viewCount: 12400, -); +Drama _drama({ + required String id, + required String title, + String dramaType = 'LIVE', + int viewCount = 0, + bool isHot = false, + DateTime? createdAt, +}) { + return Drama( + id: id, + title: title, + synopsis: 'synopsis', + posterUrl: '/files/$id.jpg', + genre: 'Romance', + category: 'Urban', + viewCount: viewCount, + isHot: isHot, + dramaType: dramaType, + createdAt: createdAt, + ); +} + +Future _pumpCard( + WidgetTester tester, { + required Drama drama, + required VoidCallback onTap, +}) { + return tester.pumpWidget( + MaterialApp( + theme: AppTheme.dark(), + locale: const Locale('en'), + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: AppLocalizations.localizationsDelegates, + home: Scaffold( + body: Center( + child: SizedBox( + width: 130, + height: 240, + child: HomeFeedCard( + drama: drama, + onTap: onTap, + now: DateTime(2026, 10, 22, 12), + ), + ), + ), + ), + ), + ); +} diff --git a/test/home/home_grid_layout_test.dart b/test/home/home_grid_layout_test.dart new file mode 100644 index 0000000..5ae8b09 --- /dev/null +++ b/test/home/home_grid_layout_test.dart @@ -0,0 +1,252 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/app.dart'; +import 'package:fruitshorts/core/data/models/content_category.dart'; +import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/models/feed_page.dart'; +import 'package:fruitshorts/core/data/models/home_feed.dart'; +import 'package:fruitshorts/core/data/repositories/catalog_repository.dart'; +import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; +import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; +import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; +import 'package:fruitshorts/pages/player/player_view.dart'; +import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; + +import '../support/fake_category.dart'; +import '../support/fake_haptics.dart'; +import '../support/fake_interaction_repositories.dart'; +import '../support/widget_player.dart'; + +const _categories = [ + ContentCategory(id: 'costume', name: '古装'), + ContentCategory(id: 'community', name: '社群'), + ContentCategory(id: 'uplifting', name: '爽剧'), + ContentCategory(id: 'sweet', name: '甜宠'), + ContentCategory(id: 'face-slap', name: '打脸虐渣'), + ContentCategory(id: 'angst', name: '虐恋'), +]; + +void main() { + setUp(() { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + }); + + testWidgets('should_render_primary_sections_and_secondary_tags_in_order', ( + tester, + ) async { + final repository = _PagedCatalogRepository(); + await _pumpApp(tester, repository); + + final sectionKeys = [ + const Key('home-section-featured'), + const Key('home-section-new'), + const Key('home-section-categories'), + const Key('home-section-ranking'), + const Key('home-section-category-古装'), + const Key('home-section-category-社群'), + ]; + final sectionCenters = sectionKeys + .map((key) => tester.getCenter(find.byKey(key)).dx) + .toList(); + expect(sectionCenters, orderedEquals(sectionCenters.toList()..sort())); + + final tagKeys = [ + const Key('home-tag-hot'), + const Key('home-tag-uplifting'), + const Key('home-tag-sweet'), + const Key('home-tag-faceSlap'), + const Key('home-tag-angst'), + ]; + final tagCenters = tagKeys + .map((key) => tester.getCenter(find.byKey(key)).dx) + .toList(); + expect(tagCenters, orderedEquals(tagCenters.toList()..sort())); + expect(find.text('Featured'), findsOneWidget); + expect(find.text('New'), findsOneWidget); + expect(find.text('Categories'), findsOneWidget); + expect(find.text('Charts'), findsOneWidget); + expect(find.text('Costume'), findsOneWidget); + expect(find.text('Community'), findsOneWidget); + }); + + testWidgets('should_hide_unmatched_content_sections', (tester) async { + final repository = _PagedCatalogRepository(); + await _pumpApp( + tester, + repository, + categories: const [ContentCategory(id: 'romance', name: 'Romance')], + ); + + expect(find.byKey(const Key('home-section-category-古装')), findsNothing); + expect(find.byKey(const Key('home-section-category-社群')), findsNothing); + expect(find.byKey(const Key('home-tag-uplifting')), findsNothing); + }); + + testWidgets('should_open_existing_pages_from_home_sections', (tester) async { + final repository = _PagedCatalogRepository(); + await _pumpApp(tester, repository); + + await tester.tap(find.byKey(const Key('home-section-new'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('new-releases-page')), findsOneWidget); + await tester.pageBack(); + await tester.pumpAndSettle(); + + await tester.ensureVisible(find.byKey(const Key('home-section-ranking'))); + await tester.tap(find.byKey(const Key('home-section-ranking'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('ranking-page')), findsOneWidget); + await tester.pageBack(); + await tester.pumpAndSettle(); + + await tester.ensureVisible( + find.byKey(const Key('home-section-categories')), + ); + await tester.tap(find.byKey(const Key('home-section-categories'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('filter-page')), findsOneWidget); + }); + + testWidgets('should_load_next_page_without_expanding_page_size', ( + tester, + ) async { + final repository = _PagedCatalogRepository(); + await _pumpApp(tester, repository); + + expect(find.byKey(const Key('home-feed-card-page1-0')), findsOneWidget); + await tester.drag( + find.byKey(const Key('home-grid-scroll')), + const Offset(0, -1200), + ); + await tester.pumpAndSettle(); + expect(repository.cursors, contains('page1')); + expect(repository.requestedSizes.every((size) => size <= 10), isTrue); + + await tester.scrollUntilVisible( + find.byKey(const Key('home-feed-card-page2-1')), + 260, + scrollable: find + .descendant( + of: find.byKey(const Key('home-grid-refresh')), + matching: find.byType(Scrollable), + ) + .first, + ); + expect(find.byKey(const Key('home-feed-card-page2-1')), findsOneWidget); + }); + + testWidgets('should_open_player_when_grid_card_is_tapped', (tester) async { + final repository = _PagedCatalogRepository(); + await _pumpApp(tester, repository); + + await tester.tap(find.byKey(const Key('home-feed-card-page1-0'))); + await tester.pumpAndSettle(); + + final player = tester.widget(find.byType(PlayerPage).last); + expect(player.homeMode, isFalse); + }); +} + +Future _pumpApp( + WidgetTester tester, + _PagedCatalogRepository repository, { + List categories = _categories, +}) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( + LumaDramaApp( + playerFactory: createWidgetPlayer, + catalogRepository: repository, + categoryRepository: FakeCategoryRepository( + catalog: repository, + categories: categories, + ), + watchHistoryRepository: InMemoryWatchHistoryRepository(), + favoriteRepository: InMemoryFavoriteRepository( + cards: repository.allDramas, + ), + playbackRepository: FakePlaybackRepository(), + commentRepository: FakeCommentRepository(), + interactionRepository: FakeInteractionRepository(), + haptics: FakeHaptics(), + ), + ); + await tester.pumpAndSettle(); +} + +class _PagedCatalogRepository implements CatalogRepository { + _PagedCatalogRepository() + : pageOne = List.generate(10, (index) => _drama('page1-$index')), + pageTwo = List.generate(2, (index) => _drama('page2-$index')); + + final List pageOne; + final List pageTwo; + final List cursors = []; + final List requestedSizes = []; + + List get allDramas => [...pageOne, ...pageTwo]; + + @override + Future fetchHomeFeed() async => _feed(pageOne); + + @override + Future fetchDiscoverFeed() async => _feed(pageOne); + + @override + Future fetchFeed({ + List types = const [], + String? categoryId, + String? cursor, + int size = 10, + }) async { + requestedSizes.add(size); + if (types.isNotEmpty) { + return const FeedPage(dramas: []); + } + cursors.add(cursor); + if (cursor == 'page1') { + return FeedPage(dramas: pageTwo, hasMore: false); + } + return FeedPage(dramas: pageOne, nextCursor: 'page1', hasMore: true); + } + + @override + Future fetchDrama(String dramaId) async { + return allDramas.firstWhere((drama) => drama.id == dramaId); + } + + HomeFeed _feed(List dramas) { + return HomeFeed( + sections: [FeedSection(code: 'hot', title: 'Hot', dramas: dramas)], + ); + } +} + +Drama _drama(String id) { + return Drama( + id: id, + title: 'Grid Drama $id', + synopsis: 'synopsis', + posterUrl: '', + genre: 'Romance', + category: 'Urban', + viewCount: 200000, + detailLoaded: true, + episodes: [ + Episode( + id: '$id-1', + number: 1, + title: 'Episode 1', + isFree: true, + videoUrl: 'https://example.test/$id.mp4', + ), + ], + ); +} diff --git a/test/home/home_nav_ui_test.dart b/test/home/home_nav_ui_test.dart index 0e044f0..3ba8edd 100644 --- a/test/home/home_nav_ui_test.dart +++ b/test/home/home_nav_ui_test.dart @@ -209,11 +209,11 @@ void main() { expect(find.byKey(const Key('tab-follow')), findsOneWidget); expect(find.byKey(const Key('tab-mine')), findsOneWidget); expect(find.byKey(const Key('tab-create')), findsNothing); - expect(find.byKey(const Key('home-feed-title')), findsOneWidget); + expect(find.byKey(const Key('home-grid')), findsOneWidget); await tester.tap(find.byKey(const Key('tab-recommend'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); + expect(find.byKey(const Key('home-feed-title')), findsOneWidget); await tester.tap(find.byKey(const Key('tab-follow'))); await tester.pumpAndSettle(); @@ -251,7 +251,7 @@ void main() { await tester.tap(find.byKey(const Key('tab-home'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('home-feed-title')), findsOneWidget); + expect(find.byKey(const Key('home-grid')), findsOneWidget); }); testWidgets('should_not_haptic_when_ordinary_list_item_is_opened', ( diff --git a/test/home/home_preview_layout_test.dart b/test/home/home_preview_layout_test.dart index 7a50e5a..7f36cd1 100644 --- a/test/home/home_preview_layout_test.dart +++ b/test/home/home_preview_layout_test.dart @@ -9,6 +9,7 @@ import 'package:shared_preferences_platform_interface/in_memory_shared_preferenc import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; import 'package:fruitshorts/app.dart'; +import 'package:fruitshorts/core/data/models/content_category.dart'; import 'package:fruitshorts/core/data/models/drama.dart'; import 'package:fruitshorts/core/data/models/home_feed.dart'; import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; @@ -20,6 +21,7 @@ import 'package:fruitshorts/l10n/app_localizations.dart'; import 'package:fruitshorts/pages/home/home_feed_channel.dart'; import 'package:fruitshorts/pages/player/widgets/home_feed_header.dart'; import 'package:fruitshorts/pages/player/widgets/home_feed_overlay.dart'; +import 'package:fruitshorts/pages/player/player_view.dart'; import 'package:video_player/video_player.dart'; import '../support/fake_catalog.dart'; @@ -30,7 +32,9 @@ import '../support/fake_notice_repository.dart'; import '../support/widget_player.dart'; void main() { - testWidgets('should_render_immersive_feed_on_home', (tester) async { + testWidgets('should_render_three_column_explorer_grid_on_home', ( + tester, + ) async { SharedPreferencesAsyncPlatform.instance = InMemorySharedPreferencesAsync.empty(); tester.view.physicalSize = const Size(390, 844); @@ -48,6 +52,58 @@ void main() { ), ], ); + await tester.pumpWidget( + LumaDramaApp( + playerFactory: createWidgetPlayer, + catalogRepository: catalog, + categoryRepository: FakeCategoryRepository( + catalog: catalog, + categories: const [ + ContentCategory(id: 'costume', name: '古装'), + ContentCategory(id: 'community', name: '社群'), + ], + ), + watchHistoryRepository: InMemoryWatchHistoryRepository(), + favoriteRepository: InMemoryFavoriteRepository(cards: catalog.dramas), + playbackRepository: FakePlaybackRepository(), + commentRepository: FakeCommentRepository(), + interactionRepository: FakeInteractionRepository(), + noticeRepository: FakeNoticeRepository(), + haptics: FakeHaptics(), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('home-grid')), findsOneWidget); + expect(find.byKey(const Key('home-grid-cards')), findsOneWidget); + final grid = tester.widget( + find.byKey(const Key('home-grid-cards')), + ); + final delegate = + grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount; + expect(delegate.crossAxisCount, 3); + expect(delegate.crossAxisSpacing, 8); + expect(delegate.mainAxisSpacing, 8); + expect(find.byKey(const Key('home-section-featured')), findsOneWidget); + expect(find.byKey(const Key('home-section-new')), findsOneWidget); + expect(find.byKey(const Key('home-section-categories')), findsOneWidget); + expect(find.byKey(const Key('home-section-ranking')), findsOneWidget); + expect(find.byKey(const Key('home-section-category-古装')), findsOneWidget); + expect(find.byKey(const Key('home-section-category-社群')), findsOneWidget); + expect(find.byKey(const Key('home-secondary-tags')), findsOneWidget); + expect(find.byKey(const Key('home-grid-filter')), findsOneWidget); + expect(find.byType(PageView), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('should_keep_immersive_player_on_recommend_tab', (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, @@ -63,14 +119,13 @@ void main() { ), ); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('tab-home'))); + await tester.tap(find.byKey(const Key('tab-recommend'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('home-feed-title')), findsOneWidget); - expect(find.byKey(const Key('home-feed-full-series')), findsOneWidget); + expect(find.byKey(const Key('home-grid')), findsNothing); expect(find.byType(PageView), findsOneWidget); - expect(find.byKey(const Key('home-browse-page')), findsNothing); - expect(tester.takeException(), isNull); + expect(tester.widget(find.byType(PlayerPage)).homeMode, isTrue); + expect(find.byKey(const Key('home-feed-title')), findsOneWidget); }); testWidgets('should_render_new_feed_structure_with_formatted_counts', ( diff --git a/test/home/home_server_setting_test.dart b/test/home/home_server_setting_test.dart index 3278287..d751e85 100644 --- a/test/home/home_server_setting_test.dart +++ b/test/home/home_server_setting_test.dart @@ -4,7 +4,7 @@ import 'package:shared_preferences_platform_interface/in_memory_shared_preferenc import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; import 'package:fruitshorts/l10n/app_localizations.dart'; -import 'package:fruitshorts/pages/home/home_view.dart'; +import 'package:fruitshorts/pages/home/widgets/home_failure_view.dart'; void main() { setUp(() { diff --git a/test/home/home_startup_splash_test.dart b/test/home/home_startup_splash_test.dart index d5f4666..1ec70f5 100644 --- a/test/home/home_startup_splash_test.dart +++ b/test/home/home_startup_splash_test.dart @@ -123,7 +123,7 @@ void main() { await tester.pumpAndSettle(); expect(find.byKey(const Key('startup-splash')), findsNothing); - expect(find.byKey(const Key('home-feed-title')), findsOneWidget); + expect(find.byKey(const Key('home-grid')), findsOneWidget); }); } diff --git a/test/home/k28_a_navigation_test.dart b/test/home/k28_a_navigation_test.dart index 4b3c6cf..461134f 100644 --- a/test/home/k28_a_navigation_test.dart +++ b/test/home/k28_a_navigation_test.dart @@ -46,36 +46,18 @@ void main() { expect(find.byType(AppBottomNav), findsOneWidget); }); - testWidgets('should_start_home_on_immersive_feed', (tester) async { + testWidgets('should_start_home_on_explorer_grid', (tester) async { await _pumpApp(tester); - expect(find.byKey(const Key('home-feed-title')), findsOneWidget); - expect(find.byKey(const Key('home-feed-full-series')), findsOneWidget); - expect(find.byKey(const Key('home-feed-black-background')), findsOneWidget); - expect(find.byType(PageView), findsOneWidget); - expect(find.byKey(const Key('home-browse-page')), findsNothing); - expect( - tester.widget(find.byKey(const Key('rail-like-count'))).data, - '41.9万', - ); - expect( - tester.widget(find.byKey(const Key('rail-save-count'))).data, - '1006', - ); - expect( - tester.widget(find.byKey(const Key('rail-comments-count'))).data, - '3.2万', - ); - expect(find.byKey(const Key('home-feed-refresh')), findsOneWidget); - expect(find.byKey(const Key('home-feed-danmaku')), findsNothing); - expect(find.byKey(const Key('home-feed-fullscreen')), findsNothing); + expect(find.byKey(const Key('home-grid')), findsOneWidget); + expect(find.byKey(const Key('home-grid-cards')), findsOneWidget); + expect(find.byType(PageView), findsNothing); expect( tester.widget(find.byType(Scaffold).first).backgroundColor, Colors.transparent, ); expect(find.byKey(const Key('home-page-gradient')), findsNothing); expect(_hasLightStatusBar(tester), isTrue); - expect(tester.widget(find.byType(PlayerPage)).homeMode, isTrue); }); testWidgets('should_keep_home_feed_size_when_keyboard_insets_change', ( @@ -83,7 +65,7 @@ void main() { ) async { addTearDown(tester.view.resetViewInsets); await _pumpApp(tester); - final feed = find.byKey(const Key('home-feed-black-background')); + final feed = find.byKey(const Key('home-grid')); final before = tester.getRect(feed); tester.view.viewInsets = const FakeViewPadding(bottom: 300); @@ -99,7 +81,7 @@ void main() { }); testWidgets('should_open_full_player_from_full_series_bar', (tester) async { - await _pumpApp(tester); + await _pumpApp(tester, recommend: true); await tester.tap(find.byKey(const Key('home-feed-full-series'))); await tester.pumpAndSettle(); @@ -109,7 +91,7 @@ void main() { }); testWidgets('should_switch_to_anime_channel', (tester) async { - await _pumpApp(tester); + await _pumpApp(tester, recommend: true); await tester.tap(find.byKey(const Key('home-feed-tab-anime'))); await tester.pumpAndSettle(); @@ -126,7 +108,7 @@ void main() { tester, ) async { final catalog = _PendingAnimeCatalog(); - await _pumpApp(tester, catalog: catalog); + await _pumpApp(tester, catalog: catalog, recommend: true); for (var attempt = 0; attempt < 5 && catalog.animeCalls == 0; attempt++) { await tester.pump(); } @@ -151,7 +133,7 @@ void main() { testWidgets('should_show_empty_state_when_anime_has_no_dramas', ( tester, ) async { - await _pumpApp(tester, catalog: _EmptyAnimeCatalog()); + await _pumpApp(tester, catalog: _EmptyAnimeCatalog(), recommend: true); await tester.tap(find.byKey(const Key('home-feed-tab-anime'))); await tester.pumpAndSettle(); @@ -161,7 +143,7 @@ void main() { }); testWidgets('should_refresh_feed_without_blank_loading', (tester) async { - await _pumpApp(tester); + await _pumpApp(tester, recommend: true); await tester.tap(find.byKey(const Key('home-feed-refresh'))); await tester.pump(); @@ -173,7 +155,7 @@ void main() { }); testWidgets('should_keep_feed_rail_above_bottom_panels', (tester) async { - await _pumpApp(tester); + await _pumpApp(tester, recommend: true); final railTop = tester.getCenter(find.byKey(const Key('rail-save'))).dy; final railBottom = tester .getRect(find.byKey(const Key('rail-share'))) @@ -194,7 +176,7 @@ void main() { testWidgets('should_keep_feed_header_and_progress_after_clear', ( tester, ) async { - await _pumpApp(tester); + await _pumpApp(tester, recommend: true); final gesture = await tester.startGesture(const Offset(195, 300)); await tester.pump(const Duration(milliseconds: 700)); await tester.pump(); @@ -223,7 +205,7 @@ void main() { }); testWidgets('should_boost_feed_on_side_long_press', (tester) async { - await _pumpApp(tester); + await _pumpApp(tester, recommend: true); expect(find.byKey(const Key('home-feed-title')), findsOneWidget); final gesture = await tester.startGesture(const Offset(60, 300)); @@ -245,7 +227,7 @@ void main() { }); testWidgets('should_hide_exit_clear_button_while_boosting', (tester) async { - await _pumpApp(tester); + await _pumpApp(tester, recommend: true); final moreGesture = await tester.startGesture(const Offset(195, 300)); await tester.pump(const Duration(milliseconds: 700)); await tester.pump(); @@ -282,7 +264,7 @@ void main() { testWidgets('should_resume_playback_when_boosting_while_paused', ( tester, ) async { - await _pumpApp(tester); + await _pumpApp(tester, recommend: true); await tester.tap(find.byKey(const Key('home-feed-video-toggle'))); await tester.pump(); expect(find.byKey(const Key('home-feed-center-play')), findsOneWidget); @@ -302,7 +284,7 @@ void main() { }); testWidgets('should_open_more_sheet_on_center_long_press', (tester) async { - await _pumpApp(tester); + await _pumpApp(tester, recommend: true); final gesture = await tester.startGesture(const Offset(195, 300)); await tester.pump(const Duration(milliseconds: 700)); @@ -316,7 +298,7 @@ void main() { expect(find.byKey(const Key('home-feed-pip')), findsNothing); }); - testWidgets('should_keep_light_status_bar_after_leaving_feed_tab', ( + testWidgets('should_keep_light_status_bar_in_grid_and_recommend', ( tester, ) async { await _pumpApp(tester); @@ -325,12 +307,13 @@ void main() { await tester.tap(find.byKey(const Key('tab-recommend'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('home-page-gradient')), findsOneWidget); + expect(find.byKey(const Key('home-feed-title')), findsOneWidget); + expect(find.byKey(const Key('home-page-gradient')), findsNothing); expect(_hasLightStatusBar(tester), isTrue); }); testWidgets('should_switch_feed_drama_when_swiped_up', (tester) async { - await _pumpApp(tester); + await _pumpApp(tester, recommend: true); final before = tester .widget(find.byKey(const Key('home-feed-title'))) .data; @@ -345,7 +328,7 @@ void main() { }); testWidgets('should_keep_feed_header_fixed_when_swiping', (tester) async { - await _pumpApp(tester); + await _pumpApp(tester, recommend: true); final before = tester.getRect(find.byKey(const Key('home-feed-search'))); await tester.drag(find.byType(PageView).first, const Offset(0, -600)); @@ -360,7 +343,7 @@ void main() { testWidgets('should_resume_feed_after_returning_from_full_player', ( tester, ) async { - await _pumpApp(tester); + await _pumpApp(tester, recommend: true); await tester.tap(find.byKey(const Key('home-feed-video-toggle'))); await tester.pump(); expect(find.byKey(const Key('home-feed-center-play')), findsOneWidget); @@ -379,23 +362,21 @@ void main() { expect(find.byKey(const Key('home-feed-center-play')), findsNothing); }); - testWidgets('should_hide_feed_when_switching_to_theater', (tester) async { + testWidgets('should_switch_from_grid_to_recommend_player', (tester) async { await _pumpApp(tester); - expect(find.byKey(const Key('home-feed-title')), findsOneWidget); + expect(find.byKey(const Key('home-grid')), findsOneWidget); await tester.tap(find.byKey(const Key('tab-recommend'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('home-feed-title')), findsNothing); - expect(find.byKey(const Key('discover-search')), findsOneWidget); + expect(find.byKey(const Key('home-grid')), findsNothing); + expect(find.byKey(const Key('home-feed-title')), findsOneWidget); }); - testWidgets('should_open_theater_search_when_feed_search_is_tapped', ( - tester, - ) async { + testWidgets('should_open_discover_search_from_home_search', (tester) async { await _pumpApp(tester); - await tester.tap(find.byKey(const Key('home-feed-search'))); + await tester.tap(find.byKey(const Key('home-search-entry'))); await tester.pumpAndSettle(); final field = tester.widget( @@ -404,41 +385,12 @@ void main() { expect(field.focusNode?.hasFocus, isTrue); }); - testWidgets('should_not_render_theater_language_filters', (tester) async { - await _pumpApp(tester); - await tester.tap(find.byKey(const Key('tab-recommend'))); - await tester.pumpAndSettle(); - - expect(find.byKey(const Key('theater-language-chips')), findsNothing); - expect(find.byKey(const Key('theater-category-chips')), findsOneWidget); - expect(find.byKey(const Key('discover-search')), findsOneWidget); - expect(find.byKey(const Key('discover-refresh')), findsOneWidget); - expect( - find.byKey(const Key('home-drama-card-the-signal-demo')), - findsOneWidget, - ); - }); - - testWidgets('should_open_fullscreen_player_when_theater_card_is_tapped', ( + testWidgets('should_open_player_when_home_grid_card_is_tapped', ( tester, ) async { await _pumpApp(tester); - await tester.tap(find.byKey(const Key('tab-recommend'))); - await tester.pumpAndSettle(); - - final card = find.byKey(const Key('home-drama-card-the-signal-demo')); - await tester.scrollUntilVisible( - card, - 220, - scrollable: find - .descendant( - of: find.byKey(const Key('discover-refresh')), - matching: find.byType(Scrollable), - ) - .first, - ); + final card = find.byKey(const Key('home-feed-card-the-signal-demo')); await tester.ensureVisible(card); - await tester.pumpAndSettle(); await tester.tap(card); await tester.pumpAndSettle(); @@ -446,16 +398,17 @@ void main() { expect(player.homeMode, isFalse); }); - testWidgets('should_fit_home_feed_and_theater_on_320_width', (tester) async { + testWidgets('should_fit_home_grid_and_recommend_on_320_width', ( + tester, + ) async { await _pumpApp(tester, size: const Size(320, 568)); - expect(find.byKey(const Key('home-feed-title')), findsOneWidget); + expect(find.byKey(const Key('home-grid-cards')), findsOneWidget); expect(tester.takeException(), isNull); await tester.tap(find.byKey(const Key('tab-recommend'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('theater-category-chips')), findsOneWidget); - expect(find.byKey(const Key('theater-language-chips')), findsNothing); + expect(find.byKey(const Key('home-feed-title')), findsOneWidget); expect(tester.takeException(), isNull); }); } @@ -477,6 +430,7 @@ Future _pumpApp( WidgetTester tester, { Size size = const Size(390, 844), FakeCatalogRepository? catalog, + bool recommend = false, }) async { tester.view.physicalSize = size; tester.view.devicePixelRatio = 1; @@ -497,6 +451,10 @@ Future _pumpApp( ), ); await tester.pumpAndSettle(); + if (recommend) { + await tester.tap(find.byKey(const Key('tab-recommend'))); + await tester.pumpAndSettle(); + } } class _PendingAnimeCatalog extends FakeCatalogRepository { diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 53d0b23..93399f9 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -35,13 +35,13 @@ void main() { InMemorySharedPreferencesAsync.empty(); }); - testWidgets('should_open_fullscreen_overlay_from_discover_card', ( + testWidgets('should_open_fullscreen_overlay_from_recommend_feed', ( tester, ) async { await _pumpApp(tester); await tester.tap(find.byKey(const Key('tab-recommend'))); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('home-drama-card-city-lights-demo'))); + await tester.tap(find.byKey(const Key('home-feed-full-series'))); await tester.pumpAndSettle(); _expectFullscreenOverlay(tester); @@ -59,6 +59,8 @@ void main() { tester, ) async { await _pumpApp(tester); + await tester.tap(find.byKey(const Key('tab-recommend'))); + await tester.pumpAndSettle(); _expectPlayerNavigationBar(tester); await _openSeriesPlayer(tester); @@ -78,6 +80,8 @@ void main() { ); await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); + await tester.tap(find.byKey(const Key('tab-recommend'))); + await tester.pump(); final ticketLoading = find.byKey(const Key('player-ticket-loading')); expect(ticketLoading, findsOneWidget); @@ -160,6 +164,8 @@ void main() { return player; }, ); + await tester.tap(find.byKey(const Key('tab-recommend'))); + await tester.pumpAndSettle(); final player = players.firstWhere((item) => item.playing); expect(player.playing, isTrue); @@ -215,6 +221,8 @@ void main() { }); await _pumpApp(tester); + await tester.tap(find.byKey(const Key('tab-recommend'))); + await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('rail-comments'))); await tester.pumpAndSettle(); @@ -269,6 +277,8 @@ void main() { }); final catalog = FakeCatalogRepository(dramas: [_countingDrama]); await _pumpApp(tester, catalog: catalog); + await tester.tap(find.byKey(const Key('tab-recommend'))); + await tester.pumpAndSettle(); expect( tester.widget(find.byKey(const Key('rail-save-count'))).data, @@ -865,7 +875,7 @@ void main() { ); await _openSeriesPlayer( tester, - cardKey: const Key('home-drama-card-long-copy-demo'), + cardKey: const Key('home-feed-card-long-copy-demo'), ); expect(tester.takeException(), isNull); @@ -1004,7 +1014,7 @@ void main() { ); await _openSeriesPlayer( tester, - cardKey: const Key('home-drama-card-locked-demo'), + cardKey: const Key('home-feed-card-locked-demo'), ); expect(find.byKey(const Key('progress-loading')), findsNothing); expect(find.byKey(const Key('progress-loading-highlight')), findsNothing); @@ -1329,9 +1339,9 @@ Future _clearFromMore(WidgetTester tester) async { Future _openSeriesPlayer( WidgetTester tester, { - Key cardKey = const Key('home-drama-card-city-lights-demo'), + Key cardKey = const Key('home-feed-card-city-lights-demo'), }) async { - await tester.tap(find.byKey(const Key('tab-recommend'))); + await tester.tap(find.byKey(const Key('tab-home'))); await tester.pumpAndSettle(); final card = find.byKey(cardKey); await tester.scrollUntilVisible( @@ -1339,7 +1349,7 @@ Future _openSeriesPlayer( 220, scrollable: find .descendant( - of: find.byKey(const Key('discover-refresh')), + of: find.byKey(const Key('home-grid-refresh')), matching: find.byType(Scrollable), ) .first, diff --git a/test/widget_test.dart b/test/widget_test.dart index 16f61ed..b5c7eca 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -66,9 +66,10 @@ void main() { await tester.pumpAndSettle(); // 首页 feed 启动会创建首个播放器,先清掉再统计剧场播放器。 created.clear(); - await _openTheaterPlayer(tester); + await _openRecommendFullPlayer(tester); - expect(created, hasLength(2)); + expect(created, isNotEmpty); + final createdBeforeSwipe = created.length; expect(find.byType(CircularProgressIndicator), findsNothing); await tester.drag(find.byType(PageView).last, const Offset(0, -550)); await tester.pumpAndSettle(); @@ -77,8 +78,8 @@ void main() { tester.widget(find.byKey(const Key('player-episode-heading'))).data, contains('Episode 2'), ); - expect(created, hasLength(3)); - expect(created.toSet(), hasLength(3)); + expect(created.length, greaterThan(createdBeforeSwipe)); + expect(created, contains('the-signal-demo-2')); }); testWidgets('should_open_fullscreen_player_when_theater_card_is_tapped', ( @@ -86,7 +87,7 @@ void main() { ) async { await tester.pumpWidget(appUnderTest()); await tester.pumpAndSettle(); - await _openTheaterPlayer(tester); + await _openRecommendFullPlayer(tester); final player = tester.widget(find.byType(PlayerPage).last); expect(player.homeMode, isFalse); @@ -103,22 +104,22 @@ void main() { await tester.pumpWidget(appUnderTest()); await tester.pumpAndSettle(); - await _openTheaterPlayer(tester); + await _openRecommendFullPlayer(tester); expect(find.byType(PlayerPage), findsOneWidget); await tester.tap(find.byKey(const Key('player-back'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); + expect(find.byKey(const Key('home-feed-title')), findsOneWidget); expect( Navigator.of( - tester.element(find.byKey(const Key('home-all-dramas-grid'))), + tester.element(find.byKey(const Key('home-feed-title'))), ).canPop(), isFalse, ); await tester.binding.handlePopRoute(); await tester.pumpAndSettle(); - expect(find.byKey(const Key('home-feed-title')), findsOneWidget); + expect(find.byKey(const Key('home-grid')), findsOneWidget); await tester.tap(find.byKey(const Key('tab-follow'))); await tester.pumpAndSettle(); @@ -183,7 +184,7 @@ void main() { await tester.pumpWidget(appUnderTest()); await tester.pumpAndSettle(); - await _openTheaterPlayer(tester); + await _openRecommendFullPlayer(tester); expect(find.byKey(const Key('progress-loading')), findsNothing); expect(find.byKey(const Key('progress-inactive')), findsOneWidget); @@ -197,9 +198,7 @@ void main() { expect(tester.takeException(), isNull); }); - testWidgets('should_render_theater_filters_and_grid_on_same_route_tab', ( - tester, - ) async { + testWidgets('should_render_home_explorer_grid_on_home_tab', (tester) async { tester.view.physicalSize = const Size(360, 720); tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); @@ -207,13 +206,11 @@ void main() { await tester.pumpWidget(appUnderTest()); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('tab-recommend'))); - await tester.pumpAndSettle(); - expect(find.byKey(const Key('discover-search')), findsOneWidget); - expect(find.byKey(const Key('theater-category-chips')), findsOneWidget); - expect(find.byKey(const Key('theater-language-chips')), findsNothing); - expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); + expect(find.byKey(const Key('home-grid')), findsOneWidget); + expect(find.byKey(const Key('home-grid-cards')), findsOneWidget); + expect(find.byKey(const Key('home-search-entry')), findsOneWidget); + expect(find.byKey(const Key('home-secondary-tags')), findsOneWidget); expect(tester.takeException(), isNull); }); @@ -244,7 +241,7 @@ void main() { ), ); await tester.pumpAndSettle(); - await _openTheaterPlayer(tester); + await _openRecommendFullPlayer(tester); await tester.tap(find.byKey(const Key('player-rail-comments'))); await tester.pumpAndSettle(); @@ -264,13 +261,13 @@ void main() { await tester.tap(find.byKey(const Key('player-rail-like'))); await tester.pumpAndSettle(); - expect(interactions.likeTargets, contains('EPISODE:city-lights-demo-1')); + expect(interactions.likeTargets, contains('EPISODE:the-signal-demo-1')); await tester.tap(find.byKey(const Key('player-rail-save'))); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('player-rail-share'))); await tester.pumpAndSettle(); - expect(sharedText, contains('City Lights & Velvet Nights')); + expect(sharedText, contains('The Signal')); expect(sharedText, contains('FruitShorts')); expect(tester.takeException(), isNull); }); @@ -284,7 +281,7 @@ void main() { await tester.pumpWidget(appUnderTest(interactionRepository: interactions)); await tester.pumpAndSettle(); - await _openTheaterPlayer(tester); + await _openRecommendFullPlayer(tester); await tester.tap(find.byKey(const Key('player-rail-like'))); await tester.pumpAndSettle(); @@ -300,7 +297,7 @@ void main() { await tester.pumpWidget(appUnderTest()); await tester.pumpAndSettle(); - await _openTheaterPlayer(tester); + await _openRecommendFullPlayer(tester); await tester.tap(find.byKey(const Key('player-rail-save'))); await tester.pumpAndSettle(); @@ -311,26 +308,13 @@ void main() { await tester.tap(find.byKey(const Key('mine-watchlist'))); await tester.pumpAndSettle(); - expect(find.text('City Lights & Velvet Nights'), findsWidgets); + expect(find.text('The Signal'), findsWidgets); }); } -Future _openTheaterPlayer(WidgetTester tester) async { +Future _openRecommendFullPlayer(WidgetTester tester) async { await tester.tap(find.byKey(const Key('tab-recommend'))); await tester.pumpAndSettle(); - final card = find.byKey(const Key('home-drama-card-city-lights-demo')); - await tester.scrollUntilVisible( - card, - 220, - scrollable: find - .descendant( - of: find.byKey(const Key('discover-refresh')), - matching: find.byType(Scrollable), - ) - .first, - ); - await tester.ensureVisible(card); - await tester.pumpAndSettle(); - await tester.tap(card); + await tester.tap(find.byKey(const Key('home-feed-full-series'))); await tester.pumpAndSettle(); }