feat(flutter): 重构四 Tab 与浏览页

This commit is contained in:
Zhou Weidong committed 2026-10-20 09:49:42 +08:00
1 parent 99c72e2c32
commit 959edc0a32
41 files changed
+2148 -1179

No files matched your search

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