merge: 首页切换三列探索网格
This commit is contained in:
commit
47bf6458e8
30 files changed
+1952
-256
No files matched your search
@@ -863,3 +863,43 @@ $ flutter test
|
||||
|
||||
解读:四等分、56 内容高度、安全区、26 图标、4 间距、精确色值和无分割线
|
||||
均有测试覆盖;645 项测试全部通过。`pubspec.lock` 未提交,未操作真机。
|
||||
|
||||
## 2026-10-22 首页改为三列探索网格
|
||||
|
||||
- 分支:`feature/home-grid-layout`
|
||||
- 基线:`develop` / `origin/develop` = `6dea9d4`
|
||||
- 实现提交:`dd96792 feat(home): 首页改为三列探索网格`
|
||||
|
||||
### 布局与数据
|
||||
|
||||
- 首页按参考图改为纯黑探索页:搜索框、皇冠、礼物入口、一级分类、
|
||||
二级标签与三列封面网格。
|
||||
- `HomeFeedController` 继续使用 10 条游标分页;支持下拉刷新、触底加载、
|
||||
空态与失败重试,没有全量拉取。
|
||||
- 一级固定项为精选/新剧/分类/榜单;古装/社群仅在 `/app/categories`
|
||||
名称匹配时显示。新剧/分类/榜单复用现有页面。
|
||||
- 二级标签按分类与题材可用性显示,筛选按钮复用 `FilterPage`。
|
||||
- 卡片封面 5:6、圆角 8、间距 8;包含真实热剧/新剧/漫剧角标、
|
||||
播放量、最多两行标题与最多两个题材标签。
|
||||
- `Drama` 增加 `dramaType` 和可选 `createdAt`;卡片未下发上架时间时
|
||||
从 Snowflake id 解析,不使用假数据。
|
||||
- 原 `PlayerPage(homeMode: true)` 保留并迁至推荐 tab,播放器文件与测试未删除。
|
||||
- 首页顶部中性色、卡片与描边 token 已沉淀到 `AppColors/AppDimens`,
|
||||
后续 P14 复用。
|
||||
|
||||
### 门禁命令与原始输出
|
||||
|
||||
```text
|
||||
$ flutter analyze
|
||||
Analyzing reelshort-bottom-nav...
|
||||
No issues found! (ran in 2.7s)
|
||||
```
|
||||
|
||||
```text
|
||||
$ flutter test
|
||||
02:32 +651: All tests passed!
|
||||
```
|
||||
|
||||
解读:三列网格、分类顺序、动态入口隐藏、角标、卡片信息、分页大小、
|
||||
下一页追加和卡片进入播放器均有测试覆盖。`home_view.dart` 为 478 行,
|
||||
其余新增组件均低于 500 行。`pubspec.lock` 未提交,未操作真机。
|
||||
@@ -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<Map<String, dynamic>>()
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+18
-1
@@ -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"
|
||||
}
|
||||
+18
-1
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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 => '清除筛选';
|
||||
}
|
||||
+18
-1
@@ -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": "清除筛选"
|
||||
}
|
||||
@@ -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<HomePage> 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<SystemUiOverlayStyle>(
|
||||
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<void>(
|
||||
MaterialPageRoute(builder: (_) => const DiscoverPage(focusSearch: true)),
|
||||
);
|
||||
}
|
||||
|
||||
void _pushPage(BuildContext context, Widget page) {
|
||||
Navigator.of(context).push<void>(MaterialPageRoute(builder: (_) => page));
|
||||
}
|
||||
|
||||
Future<void> _openVip(BuildContext context) async {
|
||||
if (!await ensureSignedIn(context) || !context.mounted) return;
|
||||
final wallet = context.read<WalletController>();
|
||||
await Navigator.of(context).push<void>(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => VipPage(onPurchased: () => unawaited(wallet.load())),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openWallet(BuildContext context) async {
|
||||
if (!await ensureSignedIn(context) || !context.mounted) return;
|
||||
await Navigator.of(
|
||||
context,
|
||||
).push<void>(MaterialPageRoute(builder: (_) => const WalletPage()));
|
||||
}
|
||||
|
||||
Widget _my(BuildContext context) {
|
||||
return MyPage(
|
||||
active: logic.tab == 3,
|
||||
|
||||
@@ -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<String, List<String>> _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);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<String> _cardTags() {
|
||||
final seen = <String>{};
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<String> _costumeAliases = ['古装', 'costume', 'period'];
|
||||
const List<String> _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<Drama> onOpenDrama;
|
||||
|
||||
@override
|
||||
State<HomeFeedGrid> createState() => _HomeFeedGridState();
|
||||
}
|
||||
|
||||
class _HomeFeedGridState extends State<HomeFeedGrid> {
|
||||
String? _selectedCategory;
|
||||
String? _selectedTag;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final categories = context.watch<CategoryController>().categories;
|
||||
final auth = context.watch<AuthController>();
|
||||
final wallet = context.watch<WalletController>();
|
||||
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<ScrollNotification>(
|
||||
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<void> _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<HomeGridCategoryShortcut> _categoryShortcuts(
|
||||
List<ContentCategory> categories,
|
||||
AppLocalizations l10n,
|
||||
) {
|
||||
final shortcuts = <HomeGridCategoryShortcut>[];
|
||||
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<ContentCategory> categories,
|
||||
List<String> aliases,
|
||||
) {
|
||||
for (final category in categories) {
|
||||
final name = category.name.toLowerCase();
|
||||
if (aliases.any(name.contains)) return category;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
List<HomeGridTagOption> _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<CategoryController>()
|
||||
.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<Drama> _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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<HomeGridCategoryShortcut> categories;
|
||||
final List<HomeGridTagOption> tags;
|
||||
final String? selectedCategory;
|
||||
final String? selectedTag;
|
||||
final ValueChanged<String?> onCategorySelected;
|
||||
final ValueChanged<String?> 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -151,7 +151,7 @@ Future<void> _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();
|
||||
|
||||
@@ -455,7 +455,9 @@ Future<void> _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();
|
||||
}
|
||||
|
||||
|
||||
@@ -303,7 +303,7 @@ Future<void> _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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<Container>(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<Text>(
|
||||
find.descendant(of: views, matching: find.byType(Text)),
|
||||
);
|
||||
expect(viewsText.data, startsWith('44.'));
|
||||
expect(
|
||||
tester
|
||||
.widget<Text>(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<Container>(
|
||||
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<Container>(
|
||||
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<void> _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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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<PlayerPage>(find.byType(PlayerPage).last);
|
||||
expect(player.homeMode, isFalse);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _pumpApp(
|
||||
WidgetTester tester,
|
||||
_PagedCatalogRepository repository, {
|
||||
List<ContentCategory> 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<Drama> pageOne;
|
||||
final List<Drama> pageTwo;
|
||||
final List<String?> cursors = [];
|
||||
final List<int> requestedSizes = [];
|
||||
|
||||
List<Drama> get allDramas => [...pageOne, ...pageTwo];
|
||||
|
||||
@override
|
||||
Future<HomeFeed> fetchHomeFeed() async => _feed(pageOne);
|
||||
|
||||
@override
|
||||
Future<HomeFeed> fetchDiscoverFeed() async => _feed(pageOne);
|
||||
|
||||
@override
|
||||
Future<FeedPage> fetchFeed({
|
||||
List<String> 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<Drama> fetchDrama(String dramaId) async {
|
||||
return allDramas.firstWhere((drama) => drama.id == dramaId);
|
||||
}
|
||||
|
||||
HomeFeed _feed(List<Drama> 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',
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -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', (
|
||||
|
||||
@@ -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<SliverGrid>(
|
||||
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<PlayerPage>(find.byType(PlayerPage)).homeMode, isTrue);
|
||||
expect(find.byKey(const Key('home-feed-title')), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('should_render_new_feed_structure_with_formatted_counts', (
|
||||
|
||||
@@ -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(() {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Text>(find.byKey(const Key('rail-like-count'))).data,
|
||||
'41.9万',
|
||||
);
|
||||
expect(
|
||||
tester.widget<Text>(find.byKey(const Key('rail-save-count'))).data,
|
||||
'1006',
|
||||
);
|
||||
expect(
|
||||
tester.widget<Text>(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<Scaffold>(find.byType(Scaffold).first).backgroundColor,
|
||||
Colors.transparent,
|
||||
);
|
||||
expect(find.byKey(const Key('home-page-gradient')), findsNothing);
|
||||
expect(_hasLightStatusBar(tester), isTrue);
|
||||
expect(tester.widget<PlayerPage>(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<Text>(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<TextField>(
|
||||
@@ -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<void> _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<void> _pumpApp(
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
if (recommend) {
|
||||
await tester.tap(find.byKey(const Key('tab-recommend')));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
}
|
||||
|
||||
class _PendingAnimeCatalog extends FakeCatalogRepository {
|
||||
|
||||
@@ -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<Text>(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<void> _clearFromMore(WidgetTester tester) async {
|
||||
|
||||
Future<void> _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<void> _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,
|
||||
|
||||
+24
-40
@@ -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<Text>(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<PlayerPage>(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<void> _openTheaterPlayer(WidgetTester tester) async {
|
||||
Future<void> _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();
|
||||
}
|
||||
Reference in new issue
Block a user