merge: 红果式三 Tab 剧场与极简我的页

This commit is contained in:
Zhou Weidong committed 2026-10-20 05:29:54 +08:00
commit 842e716ea6
54 files changed
+2354 -4598

No files matched your search

+18 -22
View File
@@ -6,7 +6,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'core/config/locale_controller.dart';
import 'core/config/app_locale.dart';
import 'core/data/remote/account_api.dart';
import 'core/data/remote/api_client.dart';
import 'core/data/remote/auth_api.dart';
@@ -238,35 +238,31 @@ class _LumaDramaAppState extends State<LumaDramaApp> {
ChangeNotifierProvider(
create: (_) => CategoryController(_categoryRepository)..load(),
),
ChangeNotifierProvider(create: (_) => LocaleController()..load()),
ChangeNotifierProvider.value(value: _favoriteController),
ChangeNotifierProvider.value(value: _downloadController),
ChangeNotifierProvider.value(value: _walletController),
ChangeNotifierProvider.value(value: _interactionController),
ChangeNotifierProvider.value(value: _auth),
],
child: Consumer<LocaleController>(
builder: (context, locale, _) => MaterialApp(
onGenerateTitle: (context) => AppLocalizations.of(context)!.appName,
locale: locale.locale,
supportedLocales: AppLocalizations.supportedLocales,
localizationsDelegates: AppLocalizations.localizationsDelegates,
localeResolutionCallback: (deviceLocale, _) =>
resolveAppLocale(locale.locale, deviceLocale),
debugShowCheckedModeBanner: false,
themeMode: ThemeMode.dark,
theme: AppTheme.light(),
darkTheme: AppTheme.dark(),
builder: (context, child) => AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle.light.copyWith(
statusBarColor: Colors.transparent,
systemNavigationBarColor: AppColors.bar,
systemNavigationBarIconBrightness: Brightness.light,
),
child: child ?? const SizedBox.shrink(),
child: MaterialApp(
onGenerateTitle: (context) => AppLocalizations.of(context)!.appName,
supportedLocales: AppLocalizations.supportedLocales,
localizationsDelegates: AppLocalizations.localizationsDelegates,
localeResolutionCallback: (deviceLocale, _) =>
resolveAppLocale(deviceLocale),
debugShowCheckedModeBanner: false,
themeMode: ThemeMode.dark,
theme: AppTheme.light(),
darkTheme: AppTheme.dark(),
builder: (context, child) => AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle.light.copyWith(
statusBarColor: Colors.transparent,
systemNavigationBarColor: AppColors.bar,
systemNavigationBarIconBrightness: Brightness.light,
),
home: const HomePage(),
child: child ?? const SizedBox.shrink(),
),
home: const HomePage(),
),
);
}
+12
View File
@@ -0,0 +1,12 @@
// Author: fruit.com.cn
import 'package:flutter/widgets.dart';
/// 将系统语言解析为受支持的语言;不支持时回退印尼语。
Locale resolveAppLocale(Locale? deviceLocale) {
return switch (deviceLocale?.languageCode) {
'zh' => const Locale('zh'),
'en' => const Locale('en'),
_ => const Locale('id'),
};
}
-56
View File
@@ -1,56 +0,0 @@
// Author: fruit.com.cn
import 'package:flutter/widgets.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Resolves the selected or system language to one of the supported app locales.
///
/// Indonesian is the launch-market fallback when a device uses an unsupported
/// language. The legacy `in` code is treated as Indonesian as well.
Locale resolveAppLocale(Locale? selected, Locale? deviceLocale) {
final code = (selected ?? deviceLocale)?.languageCode;
return switch (code) {
'zh' => const Locale('zh'),
'en' => const Locale('en'),
_ => const Locale('id'),
};
}
class LocaleController extends ChangeNotifier {
LocaleController({SharedPreferencesAsync? preferences})
: _preferences = preferences ?? SharedPreferencesAsync();
final SharedPreferencesAsync _preferences;
Locale? _locale;
Locale? get locale => _locale;
Future<void> load() async {
try {
final code = await _preferences.getString('ui.locale');
_locale = switch (code) {
'en' => const Locale('en'),
'zh' => const Locale('zh'),
'id' => const Locale('id'),
_ => null,
};
notifyListeners();
} catch (_) {
// The system locale remains usable if preferences are unavailable.
}
}
Future<void> select(String code) async {
_locale = switch (code) {
'en' => const Locale('en'),
'zh' => const Locale('zh'),
'id' => const Locale('id'),
_ => null,
};
notifyListeners();
try {
await _preferences.setString('ui.locale', code);
} catch (_) {
// Preference persistence is best effort for this prototype.
}
}
}
@@ -7,38 +7,25 @@ import '../models/drama.dart';
import '../models/home_feed.dart';
import '../repositories/category_repository.dart';
/// 发现页分类状态:服务端分类清单、发现 feed 与当前筛选。
/// 剧场页数据:服务端分类清单与发现 feed。
class CategoryController extends ChangeNotifier {
CategoryController(this._repository);
static const String allCode = 'all';
static const String hotCode = 'hot';
static const String newCode = 'new';
final CategoryRepository _repository;
List<ContentCategory> categories = const [];
HomeFeed feed = const HomeFeed();
String selectedCode = allCode;
bool loading = false;
String? error;
bool _disposed = false;
/// 当前筛选对应的分区 code;全部分类为空字符串语义。
bool get selectedSectionMissing =>
selectedCode != allCode && _selectedSection == null;
/// 当前筛选应展示的剧集;全部分类按 feed 顺序去重。
List<Drama> get visibleDramas {
if (selectedCode == allCode) return feed.dramas;
return _selectedSection?.dramas ?? const [];
}
FeedSection? get _selectedSection {
/// 分类九宫格点击后读取该分类的剧目;缺失分区返回空列表。
List<Drama> dramasOf(String categoryId) {
final code = 'category-$categoryId';
for (final section in feed.sections) {
if (section.code == selectedCode) return section;
if (section.code == code) return section.dramas;
}
return null;
return const [];
}
/// 加载分类与发现 feed;失败保留可重试错误态。
@@ -61,15 +48,6 @@ class CategoryController extends ChangeNotifier {
}
}
/// 选择服务端分类,内容只匹配 `category-{id}`。
void selectCategory(String id) => selectCode('category-$id');
/// 选择内置或分区 code。
void selectCode(String code) {
selectedCode = code;
_notify();
}
void _notify() {
if (!_disposed) notifyListeners();
}
-7
View File
@@ -83,7 +83,6 @@
"completed": "Completed",
"noSavedShows": "No shows here yet.",
"preferences": "Preferences",
"appLanguage": "App Language",
"followingTab": "Following",
"animatedTab": "Animated",
"recommendedTab": "Recommended",
@@ -245,8 +244,6 @@
"captionsOff": "Captions off",
"englishCaptions": "English captions",
"chineseCaptions": "Chinese captions",
"language": "Language",
"systemLanguage": "System",
"english": "English",
"simplifiedChinese": "简体中文",
"bahasaIndonesia": "Bahasa Indonesia",
@@ -534,10 +531,6 @@
"qualityAuto": "Auto",
"qualityAutoHint": "HLS adjusts the bitrate automatically for your connection.",
"searchFailed": "Search failed. Please try again.",
"languageFilter": "Language",
"languageChinese": "Chinese",
"languageIndonesian": "Indonesian",
"languageEnglish": "English",
"searchResultCount": "{count} results",
"@searchResultCount": { "placeholders": { "count": { "type": "int" } } }
}
-7
View File
@@ -83,7 +83,6 @@
"completed": "Selesai",
"noSavedShows": "Belum ada tontonan di sini.",
"preferences": "Preferensi",
"appLanguage": "Bahasa Aplikasi",
"followingTab": "Mengikuti",
"animatedTab": "Animasi",
"recommendedTab": "Rekomendasi",
@@ -245,8 +244,6 @@
"captionsOff": "Subtitle nonaktif",
"englishCaptions": "Subtitle bahasa Inggris",
"chineseCaptions": "Subtitle bahasa Mandarin",
"language": "Bahasa",
"systemLanguage": "Sistem",
"english": "English",
"simplifiedChinese": "简体中文",
"bahasaIndonesia": "Bahasa Indonesia",
@@ -532,10 +529,6 @@
"qualityAuto": "Otomatis",
"qualityAutoHint": "HLS menyesuaikan bitrate secara otomatis dengan koneksi Anda.",
"searchFailed": "Pencarian gagal. Coba lagi.",
"languageFilter": "Bahasa",
"languageChinese": "Mandarin",
"languageIndonesian": "Indonesia",
"languageEnglish": "Inggris",
"searchResultCount": "{count} hasil",
"@searchResultCount": { "placeholders": { "count": { "type": "int" } } }
}
-42
View File
@@ -490,12 +490,6 @@ abstract class AppLocalizations {
/// **'Preferences'**
String get preferences;
/// No description provided for @appLanguage.
///
/// In en, this message translates to:
/// **'App Language'**
String get appLanguage;
/// No description provided for @followingTab.
///
/// In en, this message translates to:
@@ -1060,18 +1054,6 @@ abstract class AppLocalizations {
/// **'Chinese captions'**
String get chineseCaptions;
/// No description provided for @language.
///
/// In en, this message translates to:
/// **'Language'**
String get language;
/// No description provided for @systemLanguage.
///
/// In en, this message translates to:
/// **'System'**
String get systemLanguage;
/// No description provided for @english.
///
/// In en, this message translates to:
@@ -2434,30 +2416,6 @@ abstract class AppLocalizations {
/// **'Search failed. Please try again.'**
String get searchFailed;
/// No description provided for @languageFilter.
///
/// In en, this message translates to:
/// **'Language'**
String get languageFilter;
/// No description provided for @languageChinese.
///
/// In en, this message translates to:
/// **'Chinese'**
String get languageChinese;
/// No description provided for @languageIndonesian.
///
/// In en, this message translates to:
/// **'Indonesian'**
String get languageIndonesian;
/// No description provided for @languageEnglish.
///
/// In en, this message translates to:
/// **'English'**
String get languageEnglish;
/// No description provided for @searchResultCount.
///
/// In en, this message translates to:
-21
View File
@@ -214,9 +214,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get preferences => 'Preferences';
@override
String get appLanguage => 'App Language';
@override
String get followingTab => 'Following';
@@ -520,12 +517,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get chineseCaptions => 'Chinese captions';
@override
String get language => 'Language';
@override
String get systemLanguage => 'System';
@override
String get english => 'English';
@@ -1252,18 +1243,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get searchFailed => 'Search failed. Please try again.';
@override
String get languageFilter => 'Language';
@override
String get languageChinese => 'Chinese';
@override
String get languageIndonesian => 'Indonesian';
@override
String get languageEnglish => 'English';
@override
String searchResultCount(int count) {
return '$count results';
-21
View File
@@ -214,9 +214,6 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get preferences => 'Preferensi';
@override
String get appLanguage => 'Bahasa Aplikasi';
@override
String get followingTab => 'Mengikuti';
@@ -522,12 +519,6 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get chineseCaptions => 'Subtitle bahasa Mandarin';
@override
String get language => 'Bahasa';
@override
String get systemLanguage => 'Sistem';
@override
String get english => 'English';
@@ -1250,18 +1241,6 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get searchFailed => 'Pencarian gagal. Coba lagi.';
@override
String get languageFilter => 'Bahasa';
@override
String get languageChinese => 'Mandarin';
@override
String get languageIndonesian => 'Indonesia';
@override
String get languageEnglish => 'Inggris';
@override
String searchResultCount(int count) {
return '$count hasil';
-21
View File
@@ -209,9 +209,6 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get preferences => '偏好设置';
@override
String get appLanguage => '应用语言';
@override
String get followingTab => '关注';
@@ -512,12 +509,6 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get chineseCaptions => '简体中文字幕';
@override
String get language => '界面语言';
@override
String get systemLanguage => '跟随系统';
@override
String get english => 'English';
@@ -1228,18 +1219,6 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get searchFailed => '搜索失败,请重试。';
@override
String get languageFilter => '语言';
@override
String get languageChinese => '中文';
@override
String get languageIndonesian => '印尼语';
@override
String get languageEnglish => '英语';
@override
String searchResultCount(int count) {
return '$count 个结果';
-7
View File
@@ -83,7 +83,6 @@
"completed": "已看完",
"noSavedShows": "这里还没有剧集。",
"preferences": "偏好设置",
"appLanguage": "应用语言",
"followingTab": "关注",
"animatedTab": "漫剧",
"recommendedTab": "推荐",
@@ -207,8 +206,6 @@
"captionsOff": "关闭字幕",
"englishCaptions": "英文字幕",
"chineseCaptions": "简体中文字幕",
"language": "界面语言",
"systemLanguage": "跟随系统",
"english": "English",
"simplifiedChinese": "简体中文",
"bahasaIndonesia": "印尼语",
@@ -494,10 +491,6 @@
"qualityAuto": "自动",
"qualityAutoHint": "HLS 会根据当前网络自动切换码率。",
"searchFailed": "搜索失败,请重试。",
"languageFilter": "语言",
"languageChinese": "中文",
"languageIndonesian": "印尼语",
"languageEnglish": "英语",
"searchResultCount": "{count} 个结果",
"@searchResultCount": { "placeholders": { "count": { "type": "int" } } }
}
@@ -0,0 +1,71 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import '../../core/data/models/drama.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_theme.dart';
import '../../core/widgets/empty_state.dart';
import '../../l10n/app_localizations.dart';
import '../drama_detail/drama_detail_view.dart';
import 'widgets/drama_grid.dart';
/// 从剧场分类九宫格进入的分类剧目列表。
class CategoryDramasPage extends StatelessWidget {
const CategoryDramasPage({
super.key,
required this.title,
required this.dramas,
this.onOpen,
});
final String title;
final List<Drama> dramas;
final ValueChanged<Drama>? onOpen;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Theme(
data: AppTheme.dark(),
child: Scaffold(
key: const Key('category-dramas-page'),
backgroundColor: AppColors.ink,
appBar: AppBar(
backgroundColor: AppColors.ink,
foregroundColor: AppColors.onSurface,
title: Text(title),
),
body: SafeArea(
top: false,
child: dramas.isEmpty
? EmptyState(
key: const Key('category-dramas-empty'),
icon: Icons.movie_filter_outlined,
title: l10n.noCategoryDramas,
subtitle: l10n.emptyHint,
)
: SingleChildScrollView(
padding: const EdgeInsets.only(top: 12, bottom: 28),
child: DramaGrid(
l10n: l10n,
dramas: dramas,
onOpen: (drama) => _open(context, drama),
),
),
),
),
);
}
void _open(BuildContext context, Drama drama) {
final callback = onOpen;
if (callback != null) {
callback(drama);
return;
}
Navigator.of(context).push<void>(
MaterialPageRoute(builder: (_) => DramaDetailPage(drama: drama)),
);
}
}
+2 -7
View File
@@ -1,14 +1,9 @@
// Author: fruit.com.cn
import '../../core/data/models/drama.dart';
import '../../core/data/repositories/search_repository.dart';
import '../../core/data/repositories/watch_history_repository.dart';
import 'discover_logic.dart';
class DiscoverBinding {
DiscoverLogic create({
required Drama drama,
required WatchHistoryRepository history,
SearchRepository? search,
}) => DiscoverLogic(drama: drama, history: history, searchRepository: search);
DiscoverLogic create({SearchRepository? search}) =>
DiscoverLogic(searchRepository: search);
}
+17 -157
View File
@@ -5,39 +5,23 @@ import 'dart:async';
import 'package:flutter/material.dart';
import '../../core/data/models/drama.dart';
import '../../core/data/models/watch_progress.dart';
import '../../core/data/remote/api_exception.dart';
import '../../core/data/repositories/search_repository.dart';
import '../../core/data/repositories/watch_history_repository.dart';
import '../../l10n/app_localizations.dart';
enum DiscoverPageAction { home, my, watchlist, detail, resume }
import 'discover_types.dart';
/// 剧场页搜索状态:服务端检索、防抖与错误态。
class DiscoverLogic extends ChangeNotifier {
DiscoverLogic({
required this.history,
required this.drama,
this.searchRepository,
}) {
refreshPosition();
}
DiscoverLogic({this.searchRepository});
final WatchHistoryRepository history;
final SearchRepository? searchRepository;
Drama drama;
late Future<Duration?> position;
WatchProgress? resumeRecord;
final TextEditingController search = TextEditingController();
final FocusNode searchFocus = FocusNode();
final ScrollController scroll = ScrollController();
bool searchOpen = false;
/// 搜索防抖与分页口径:与后端 `/app/search` 一致。
Duration searchDebounce = const Duration(milliseconds: 350);
static const int searchPageSize = 20;
List<Drama> searchResults = const [];
String? selectedLanguage;
int searchTotal = 0;
bool searchLoading = false;
String? searchError;
Timer? _searchTimer;
@@ -45,36 +29,12 @@ class DiscoverLogic extends ChangeNotifier {
int _searchRequest = 0;
bool _disposed = false;
bool get hasSearchQuery => search.text.trim().isNotEmpty;
void notify() {
if (!_disposed) notifyListeners();
}
void refreshPosition() {
// 续看位置来自服务端(每剧一条记录);未登录或没有记录时给 null,UI 显示"从头看"
position = history
.resumePointFor(drama.id)
.then((point) => point?.position)
.catchError((Object _) => null);
refreshResume();
}
Future<void> refreshResume() async {
try {
final recent = await history.recent(limit: 1);
if (recent.isEmpty || recent.first.finished) {
resumeRecord = null;
} else {
resumeRecord = recent.first;
}
} catch (_) {
resumeRecord = null;
}
notify();
}
bool get hasSearchQuery => search.text.trim().isNotEmpty;
/// 输入变化只触发后端查询;本地不再做标题/简介匹配。
void onSearchChanged() {
final query = search.text.trim();
if (query.isEmpty) {
@@ -86,47 +46,17 @@ class DiscoverLogic extends ChangeNotifier {
_scheduleSearch(query);
}
/// 搜索态展示后端结果,非搜索态展示剧场自身的可见短剧。
List<Drama> visibleDramas(List<Drama> fallback) {
if (!hasSearchQuery) return _filterByLanguage(fallback);
return _filterByLanguage(searchResults);
}
void openSearch() => searchFocus.requestFocus();
/// 当前 feed 中出现过的语种,按首次出现顺序去重。
List<String> languagesOf(List<Drama> dramas) {
final languages = <String>[];
for (final drama in dramas) {
final language = drama.language;
if (language.isNotEmpty && !languages.contains(language)) {
languages.add(language);
}
}
return languages;
}
/// 按语种筛选;null 表示全部。
void selectLanguage(String? code) {
selectedLanguage = code;
notify();
}
List<Drama> _filterByLanguage(List<Drama> dramas) {
final code = selectedLanguage;
if (code == null) return dramas;
return dramas.where((drama) => drama.language == code).toList();
}
Future<void> retrySearch() async {
void retrySearch() {
final query = search.text.trim();
if (query.isEmpty) return;
await _runSearch(query);
if (query.isNotEmpty) unawaited(_runSearch(query));
}
void _resetSearch() {
_searchTimer?.cancel();
_searchRequest++;
searchResults = const [];
searchTotal = 0;
searchLoading = false;
searchError = null;
_lastRequestedQuery = '';
@@ -162,7 +92,6 @@ class DiscoverLogic extends ChangeNotifier {
);
if (request != _searchRequest) return;
searchResults = result.list;
searchTotal = result.total;
} on ApiException catch (exception) {
_applySearchError(request, exception.message);
} catch (_) {
@@ -179,47 +108,20 @@ class DiscoverLogic extends ChangeNotifier {
if (request != _searchRequest) return;
searchError = message;
searchResults = const [];
searchTotal = 0;
}
Drama? resumeDrama(List<Drama> dramas) {
final record = resumeRecord;
if (record == null) return null;
for (final drama in dramas) {
if (drama.id == record.dramaId) return drama;
}
return null;
}
bool get showSearch => searchOpen || search.text.isNotEmpty;
void openSearch() {
searchOpen = true;
notify();
searchFocus.requestFocus();
}
void syncDrama({
/// 从播放器发起的搜索请求只在剧场页可见时抢焦点。
void syncSearch({
required bool active,
required bool wasActive,
required Drama nextDrama,
required bool dramaChanged,
required int searchRequest,
required int previousSearchRequest,
required int request,
required int previousRequest,
required bool Function() isActive,
}) {
if (active && (!wasActive || dramaChanged)) {
drama = nextDrama;
refreshPosition();
notify();
}
if (active && searchRequest != previousSearchRequest) {
searchOpen = true;
notify();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (isActive()) searchFocus.requestFocus();
});
}
if (!active || request == previousRequest) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (isActive()) searchFocus.requestFocus();
});
WidgetsBinding.instance.scheduleFrame();
}
void act(
@@ -235,48 +137,6 @@ class DiscoverLogic extends ChangeNotifier {
}
}
Drama? dramaFor(String? dramaId, List<Drama> dramas) {
for (final item in dramas) {
if (item.id == dramaId) return item;
}
return null;
}
void openCard(
BuildContext context,
Drama? selected, {
required bool embedded,
ValueChanged<Drama>? onDramaSelected,
ValueChanged<DiscoverPageAction>? onAction,
}) {
if (selected == null) {
unavailable(context);
} else if (embedded && onDramaSelected != null) {
onDramaSelected(selected);
} else {
act(
context,
DiscoverPageAction.detail,
embedded: embedded,
onAction: onAction,
);
}
}
void unavailable(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l10n.artworkOnlyNotice)));
}
void catalogInfo(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l10n.demoCatalogNotice)));
}
@override
void dispose() {
_disposed = true;
+23
View File
@@ -0,0 +1,23 @@
// Author: fruit.com.cn
import 'package:flutter/foundation.dart';
import '../../core/data/models/drama.dart';
/// 剧场页与播放器之间的返回动作。
enum DiscoverPageAction { home, my }
/// 剧场页导航回调;没有 `onAction` 时按独立页面处理。
class DiscoverPageCallbacks {
const DiscoverPageCallbacks({
this.onAction,
this.onDramaSelected,
this.onProfile,
});
final ValueChanged<DiscoverPageAction>? onAction;
final ValueChanged<Drama>? onDramaSelected;
final VoidCallback? onProfile;
bool get embedded => onAction != null;
}
+230 -653
View File
@@ -4,12 +4,12 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../../core/config/app_config.dart';
import '../../core/data/models/content_category.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/catalog_repository.dart';
import '../../core/data/repositories/search_repository.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/formatting/error_text.dart';
import '../../core/theme/app_colors.dart';
@@ -17,53 +17,34 @@ import '../../core/theme/app_dimens.dart';
import '../../core/theme/app_theme.dart';
import '../../core/widgets/app_bottom_nav.dart';
import '../../core/widgets/empty_state.dart';
import '../../core/widgets/remote_image.dart';
import '../../l10n/app_localizations.dart';
import '../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';
part 'widgets/home_drama_grid.dart';
/// 剧场页:搜索 + 分类九宫格 + 榜单 + 全部剧目。
class DiscoverPage extends StatefulWidget {
const DiscoverPage({
super.key,
required this.drama,
required this.duration,
this.focusSearch = false,
this.history,
this.searchRepository,
this.embedded = false,
this.active = true,
this.searchRequest = 0,
this.signedIn = false,
this.onSignIn,
this.onCoins,
this.onRewards,
this.onAction,
this.dramas,
this.onDramaSelected,
this.banners = const [],
this.onBannerSelected,
this.focusSearch = false,
this.callbacks = const DiscoverPageCallbacks(),
});
final Drama drama;
final Duration duration;
final bool focusSearch;
final WatchHistoryRepository? history;
final SearchRepository? searchRepository;
final bool embedded;
final bool active;
final int searchRequest;
final bool signedIn;
final VoidCallback? onSignIn;
final VoidCallback? onCoins;
final VoidCallback? onRewards;
final ValueChanged<DiscoverPageAction>? onAction;
final List<Drama>? dramas;
final ValueChanged<Drama>? onDramaSelected;
final List<FeedBanner> banners;
final ValueChanged<FeedBanner>? onBannerSelected;
final bool focusSearch;
final DiscoverPageCallbacks callbacks;
@override
State<DiscoverPage> createState() => _DiscoverPageState();
@@ -72,32 +53,28 @@ class DiscoverPage extends StatefulWidget {
class _DiscoverPageState extends State<DiscoverPage> {
late final DiscoverLogic logic;
bool get embedded => widget.callbacks.embedded;
@override
void initState() {
super.initState();
logic = DiscoverBinding().create(
drama: widget.drama,
history: widget.history ?? context.read<WatchHistoryRepository>(),
search: widget.searchRepository ?? context.read<SearchRepository>(),
);
if (widget.focusSearch) {
logic.searchOpen = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && widget.active) logic.searchFocus.requestFocus();
});
}
logic = DiscoverBinding().create(search: context.read<SearchRepository>());
if (widget.focusSearch) _focusOnFirstFrame();
}
void _focusOnFirstFrame() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && widget.active) logic.openSearch();
});
}
@override
void didUpdateWidget(covariant DiscoverPage oldWidget) {
super.didUpdateWidget(oldWidget);
logic.syncDrama(
logic.syncSearch(
active: widget.active,
wasActive: oldWidget.active,
nextDrama: widget.drama,
dramaChanged: oldWidget.drama.id != widget.drama.id,
searchRequest: widget.searchRequest,
previousSearchRequest: oldWidget.searchRequest,
request: widget.searchRequest,
previousRequest: oldWidget.searchRequest,
isActive: () => mounted && widget.active,
);
}
@@ -119,15 +96,14 @@ class _DiscoverPageState extends State<DiscoverPage> {
Widget _page(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final categories = context.watch<CategoryController>();
final allDramas = [...?widget.dramas, ...categories.visibleDramas];
final visible = logic.visibleDramas(categories.visibleDramas);
final banners = context.watch<CatalogController>().banners;
final content = SafeArea(
bottom: !widget.embedded,
bottom: !embedded,
child: Column(
children: [
_header(l10n),
Expanded(child: _feed(l10n, categories, allDramas, visible)),
if (!widget.embedded) _bottomNav(l10n),
DiscoverHeader(onSearch: logic.openSearch, onProfile: _openProfile),
Expanded(child: _feed(l10n, categories, banners)),
if (!embedded) _bottomNav(l10n),
],
),
);
@@ -135,7 +111,7 @@ class _DiscoverPageState extends State<DiscoverPage> {
value: SystemUiOverlayStyle.light,
child: Theme(
data: AppTheme.dark(),
child: widget.embedded
child: embedded
? content
: Scaffold(backgroundColor: AppColors.ink, body: content),
),
@@ -145,523 +121,222 @@ class _DiscoverPageState extends State<DiscoverPage> {
Widget _feed(
AppLocalizations l10n,
CategoryController categories,
List<Drama> allDramas,
List<Drama> visible,
) => RefreshIndicator(
key: const Key('discover-refresh'),
color: AppColors.blue,
backgroundColor: AppColors.card,
onRefresh: () async {
await Future.wait([categories.load(), logic.refreshResume()]);
},
child: CustomScrollView(
controller: logic.scroll,
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
if (widget.banners.isNotEmpty)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(top: 8),
child: FeedBannerCarousel(
banners: widget.banners,
active: widget.active,
onOpen: (banner) => widget.onBannerSelected?.call(banner),
),
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,
),
),
SliverToBoxAdapter(child: _search()),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(0, 14, 0, 12),
child: _categories(l10n, categories),
..._content(l10n, categories, banners),
],
),
);
}
List<Widget> _content(
AppLocalizations l10n,
CategoryController categories,
List<FeedBanner> banners,
) {
if (logic.hasSearchQuery) return [_searchContent(l10n)];
return _theaterContent(l10n, categories, banners);
}
Widget _searchContent(AppLocalizations l10n) => DiscoverSearchResults(
loading: logic.searchLoading,
error: logic.searchError,
results: logic.searchResults,
onRetry: logic.retrySearch,
onOpen: _openDrama,
);
List<Widget> _theaterContent(
AppLocalizations l10n,
CategoryController categories,
List<FeedBanner> banners,
) {
if (categories.loading && categories.feed.sections.isEmpty) {
return [_block(const CircularProgressIndicator(strokeWidth: 2))];
}
final error = categories.error;
if (error != null) {
return [
_block(
EmptyState(
key: const Key('home-categories-error'),
icon: Icons.cloud_off_rounded,
title: errorText(l10n, error, l10n.categoriesError),
subtitle: l10n.emptyHint,
actionLabel: l10n.retry,
onAction: categories.load,
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(0, 0, 0, 12),
child: _languages(l10n, allDramas),
];
}
return _theaterBlocks(l10n, categories, banners);
}
List<Widget> _theaterBlocks(
AppLocalizations l10n,
CategoryController categories,
List<FeedBanner> banners,
) {
final dramas = categories.feed.dramas;
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 (logic.resumeRecord != null && logic.resumeDrama(allDramas) != null)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 14),
child: _continueCard(
l10n,
logic.resumeDrama(allDramas)!,
logic.resumeRecord!,
),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 14),
child: _quickActions(l10n, categories),
),
if (categories.feed.sections.isNotEmpty || dramas.isNotEmpty)
RankingSection(
sections: categories.feed.sections,
fallback: dramas,
onOpen: _openDrama,
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 2, 14, 10),
child: Row(
children: [
Container(
width: 3,
height: 18,
decoration: BoxDecoration(
color: AppColors.blue,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 6),
Expanded(
child: Text(
l10n.trendingMicroDramas,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: AppDimens.previewSectionTitle,
fontWeight: FontWeight.w800,
),
),
),
TextButton(
onPressed: () =>
categories.selectCode(CategoryController.allCode),
style: TextButton.styleFrom(
foregroundColor: AppColors.accent,
minimumSize: const Size(44, 44),
padding: EdgeInsets.zero,
),
child: Text(
'${l10n.all} ›',
style: const TextStyle(fontSize: 10),
),
),
],
),
),
),
_HomeDramaGrid(
l10n: l10n,
categories: categories,
dramas: visible,
searching: logic.hasSearchQuery,
searchLoading: logic.searchLoading,
searchError: logic.searchError,
onRetrySearch: () => logic.retrySearch(),
onOpen: (drama) => logic.openCard(
context,
drama,
embedded: widget.embedded,
onDramaSelected: widget.onDramaSelected,
onAction: widget.onAction,
),
),
],
_sectionTitle(l10n.allDramas),
if (dramas.isEmpty) _emptyCatalog(l10n) else _dramaGrid(l10n, dramas),
];
}
Widget _block(Widget child) =>
SizedBox(height: 280, child: Center(child: child));
Widget _emptyCatalog(AppLocalizations l10n) => Padding(
padding: const EdgeInsets.symmetric(vertical: 40),
child: EmptyState(
key: const Key('discover-empty'),
icon: Icons.movie_filter_outlined,
title: l10n.noStories,
subtitle: l10n.emptyHint,
),
);
Widget _header(AppLocalizations l10n) {
return SizedBox(
height: 46,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 14),
child: 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,
),
Widget _dramaGrid(AppLocalizations l10n, List<Drama> dramas) {
return DramaGrid(l10n: l10n, dramas: dramas, onOpen: _openDrama);
}
Widget _sectionTitle(String title) {
return Padding(
padding: const EdgeInsets.fromLTRB(14, 18, 14, 10),
child: Row(
children: [
Container(
width: 3,
height: 18,
decoration: BoxDecoration(
color: AppColors.blue,
borderRadius: BorderRadius.circular(2),
),
const SizedBox(width: 8),
Text(
l10n.discoverTab,
),
const SizedBox(width: 6),
Expanded(
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: 'Outfit',
color: AppColors.onSurface,
fontSize: 15,
fontSize: AppDimens.previewSectionTitle,
fontWeight: FontWeight.w800,
),
),
const Spacer(),
_HeaderIcon(
key: const Key('discover-search-button'),
icon: Icons.search_rounded,
tooltip: l10n.search,
onTap: logic.openSearch,
),
const SizedBox(width: 8),
Semantics(
button: true,
label: widget.signedIn ? l10n.myTab : l10n.logIn,
child: InkWell(
key: const Key('discover-profile'),
customBorder: const CircleBorder(),
onTap: widget.signedIn ? widget.onCoins : widget.onSignIn,
child: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF6A5CFF), AppColors.blue],
),
),
child: const Icon(
Icons.person_rounded,
color: AppColors.onSurface,
size: 18,
),
),
),
),
],
),
),
);
}
Widget _search() {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 14),
child: SizedBox(
height: 36,
child: TextField(
key: const Key('discover-search'),
controller: logic.search,
focusNode: logic.searchFocus,
onChanged: (_) => logic.onSearchChanged(),
onTapOutside: (_) => logic.searchFocus.unfocus(),
textInputAction: TextInputAction.search,
style: const TextStyle(color: AppColors.onSurface, fontSize: 13),
decoration: InputDecoration(
isDense: true,
filled: true,
fillColor: AppColors.input,
hintText: AppLocalizations.of(context)!.searchDiscover,
hintStyle: const TextStyle(color: AppColors.cream, fontSize: 11),
prefixIcon: const Icon(
Icons.search_rounded,
color: AppColors.subtle,
size: 17,
),
suffixIcon: logic.searchLoading
? const Padding(
padding: EdgeInsets.all(11),
child: CircularProgressIndicator(strokeWidth: 2),
)
: null,
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(20),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(20),
borderSide: const BorderSide(color: AppColors.blue, width: 1),
),
),
),
),
);
}
Widget _categories(AppLocalizations l10n, CategoryController categories) {
final choices = <({String code, String label})>[
(code: CategoryController.allCode, label: l10n.recommended),
for (final category in categories.categories)
(code: 'category-${category.id}', label: category.name),
];
return SizedBox(
key: const Key('home-category-chips'),
height: AppDimens.tapTarget,
child: ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 14),
scrollDirection: Axis.horizontal,
itemCount: choices.length,
separatorBuilder: (_, _) => const SizedBox(width: 8),
itemBuilder: (context, index) {
final choice = choices[index];
final selected = categories.selectedCode == choice.code;
return Semantics(
selected: selected,
button: true,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => categories.selectCode(choice.code),
child: Center(
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
alignment: Alignment.center,
padding: const EdgeInsets.symmetric(horizontal: 15),
decoration: BoxDecoration(
color: selected ? AppColors.blue : const Color(0xFF29272B),
borderRadius: BorderRadius.circular(999),
border: Border.all(
color: selected ? AppColors.blue : Colors.transparent,
),
),
child: Text(
choice.label,
maxLines: 1,
style: TextStyle(
color: selected ? AppColors.onPrimary : AppColors.muted,
fontSize: AppDimens.chipFont,
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
height: 1.1,
),
),
),
),
),
);
},
),
);
}
Widget _languages(AppLocalizations l10n, List<Drama> dramas) {
final languages = logic.languagesOf(dramas);
if (languages.isEmpty) return const SizedBox.shrink();
final selected = logic.selectedLanguage;
return SizedBox(
key: const Key('discover-language-chips'),
height: AppDimens.tapTarget,
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: 14),
scrollDirection: Axis.horizontal,
children: [
_languageChip(
key: const Key('discover-language-all'),
label: l10n.all,
selected: selected == null,
onTap: () => logic.selectLanguage(null),
),
for (final language in languages)
_languageChip(
key: Key('discover-language-$language'),
label: _languageLabel(l10n, language),
selected: selected == language,
onTap: () => logic.selectLanguage(language),
),
],
),
);
}
Widget _languageChip({
required Key key,
required String label,
required bool selected,
required VoidCallback onTap,
}) {
return Padding(
padding: const EdgeInsets.only(right: 8),
child: Center(
child: ChoiceChip(
key: key,
label: Text(label),
selected: selected,
showCheckmark: false,
onSelected: (_) => onTap(),
selectedColor: AppColors.blue,
backgroundColor: const Color(0xFF29272B),
side: BorderSide.none,
labelStyle: TextStyle(
color: selected ? AppColors.onPrimary : AppColors.muted,
fontSize: AppDimens.chipFont,
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
),
),
),
);
}
String _languageLabel(AppLocalizations l10n, String code) {
if (code == 'zh-CN' || code == 'zh') return l10n.languageChinese;
if (code == 'id') return l10n.languageIndonesian;
if (code == 'en') return l10n.languageEnglish;
return code;
}
Widget _continueCard(
AppLocalizations l10n,
Drama drama,
WatchProgress record,
) {
final progress = record.fraction;
return Container(
key: const Key('discover-continue-watching'),
padding: const EdgeInsets.all(9),
decoration: BoxDecoration(
color: AppColors.cardAlt,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(10),
child: SizedBox(
width: 52,
height: 66,
child: RemoteImage(
drama.posterUrl,
fit: BoxFit.cover,
showFallbackIcon: true,
),
),
),
const SizedBox(width: 9),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.continueWatching,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.accent,
fontSize: 8,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 2),
Text(
drama.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 12,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 2),
Text(
_resumeShort(drama, record),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Color(0xFFBBBBBB), fontSize: 9),
),
const SizedBox(height: 7),
ClipRRect(
borderRadius: BorderRadius.circular(999),
child: LinearProgressIndicator(
value: progress <= 0 ? null : progress,
minHeight: 3,
backgroundColor: const Color(0xFF474248),
valueColor: const AlwaysStoppedAnimation(AppColors.blue),
),
),
],
),
),
const SizedBox(width: 10),
FilledButton(
key: const Key('discover-continue-play'),
onPressed: () => logic.openCard(
context,
drama,
embedded: widget.embedded,
onDramaSelected: widget.onDramaSelected,
onAction: widget.onAction,
),
style: FilledButton.styleFrom(
backgroundColor: AppColors.blue,
foregroundColor: AppColors.onPrimary,
minimumSize: const Size(56, 28),
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(9),
),
textStyle: const TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
),
),
child: Text(l10n.resume),
),
],
),
);
}
String _resumeShort(Drama drama, WatchProgress record) {
final label = AppLocalizations.of(context)!;
Episode? episode;
for (final item in drama.episodes) {
if (item.id == record.episodeId) {
episode = item;
break;
void _openProfile() {
final callback = widget.callbacks.onProfile;
if (callback != null) {
callback();
return;
}
_act(DiscoverPageAction.my);
}
void _act(DiscoverPageAction action) {
logic.act(
context,
action,
embedded: embedded,
onAction: widget.callbacks.onAction,
);
}
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) {
callback(drama);
return;
}
_pushDetail(drama);
}
void _pushDetail(Drama drama) {
Navigator.of(context).push<void>(
MaterialPageRoute(builder: (_) => DramaDetailPage(drama: drama)),
);
}
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;
}
}
if (episode == null) return _duration(record.position);
return label.episodeOfTotal(episode.number, drama.episodeCount);
}
Widget _quickActions(AppLocalizations l10n, CategoryController categories) {
return Row(
children: [
Expanded(
child: _QuickAction(
key: const Key('discover-quick-filter'),
icon: Icons.tune_rounded,
label: l10n.filter,
color: AppColors.secondary,
onTap: logic.openSearch,
),
),
const SizedBox(width: 7),
Expanded(
child: _QuickAction(
key: const Key('discover-quick-charts'),
icon: Icons.local_fire_department_rounded,
label: l10n.topCharts,
color: AppColors.blue,
onTap: () => categories.selectCode(CategoryController.hotCode),
),
),
const SizedBox(width: 7),
Expanded(
child: _QuickAction(
key: const Key('discover-quick-new'),
icon: Icons.auto_awesome_rounded,
label: l10n.newReleases,
color: AppColors.gold,
onTap: () => categories.selectCode(CategoryController.newCode),
),
),
const SizedBox(width: 7),
Expanded(
child: _QuickAction(
key: const Key('discover-quick-watchlist'),
icon: Icons.bookmark_border_rounded,
label: l10n.watchlist,
color: AppColors.success,
onTap: () => logic.act(
context,
DiscoverPageAction.watchlist,
embedded: widget.embedded,
onAction: widget.onAction,
),
),
),
],
);
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(
@@ -671,12 +346,7 @@ class _DiscoverPageState extends State<DiscoverPage> {
icon: Icons.play_circle_outline_rounded,
selectedIcon: Icons.play_circle_fill_rounded,
label: l10n.homeTab,
onTap: () => logic.act(
context,
DiscoverPageAction.home,
embedded: widget.embedded,
onAction: widget.onAction,
),
onTap: () => _act(DiscoverPageAction.home),
),
AppBottomNavItem(
tabKey: const Key('discover-nav-current'),
@@ -691,101 +361,8 @@ class _DiscoverPageState extends State<DiscoverPage> {
icon: Icons.video_library_outlined,
selectedIcon: Icons.video_library_rounded,
label: l10n.myTab,
onTap: () => logic.act(
context,
DiscoverPageAction.my,
embedded: widget.embedded,
onAction: widget.onAction,
),
onTap: () => _act(DiscoverPageAction.my),
),
],
);
}
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),
),
);
}
}
class _QuickAction extends StatelessWidget {
const _QuickAction({
super.key,
required this.icon,
required this.label,
required this.color,
required this.onTap,
});
final IconData icon;
final String label;
final Color color;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Material(
color: AppColors.quick,
borderRadius: BorderRadius.circular(12),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Container(
height: 62,
padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 5),
decoration: BoxDecoration(borderRadius: BorderRadius.circular(12)),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, color: color, size: 16),
const SizedBox(height: 4),
Text(
label,
maxLines: 2,
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.muted,
fontSize: 9,
fontWeight: FontWeight.w500,
height: 1.05,
),
),
],
),
),
),
);
}
}
String _duration(Duration value) {
final minutes = value.inMinutes.remainder(60).toString().padLeft(2, '0');
final seconds = value.inSeconds.remainder(60).toString().padLeft(2, '0');
return '$minutes:$seconds';
}
File renamed without changes.
@@ -0,0 +1,114 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import '../../../core/data/models/content_category.dart';
import '../../../core/theme/app_colors.dart';
const double _gridPadding = 14;
const double _tileSpacing = 8;
const double _tileAspectRatio = 1.1;
const int _columns = 3;
const List<IconData> _icons = [
Icons.favorite_rounded,
Icons.bolt_rounded,
Icons.theater_comedy_rounded,
Icons.auto_awesome_rounded,
Icons.psychology_alt_rounded,
Icons.local_fire_department_rounded,
Icons.family_restroom_rounded,
Icons.work_history_rounded,
];
/// 剧场顶部分类九宫格。
class CategoryGrid extends StatelessWidget {
const CategoryGrid({
super.key,
required this.categories,
required this.onTap,
});
final List<ContentCategory> categories;
final ValueChanged<ContentCategory> onTap;
@override
Widget build(BuildContext context) {
if (categories.isEmpty) return const SizedBox.shrink();
return GridView.builder(
key: const Key('discover-category-grid'),
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
padding: const EdgeInsets.symmetric(horizontal: _gridPadding),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: _columns,
mainAxisSpacing: _tileSpacing,
crossAxisSpacing: _tileSpacing,
childAspectRatio: _tileAspectRatio,
),
itemCount: categories.length,
itemBuilder: (context, index) {
final category = categories[index];
return _CategoryTile(
category: category,
icon: _icons[index % _icons.length],
onTap: () => onTap(category),
);
},
);
}
}
class _CategoryTile extends StatelessWidget {
const _CategoryTile({
required this.category,
required this.icon,
required this.onTap,
});
final ContentCategory category;
final IconData icon;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Material(
key: Key('discover-category-${category.id}'),
color: AppColors.card,
borderRadius: BorderRadius.circular(12),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 30,
height: 30,
decoration: BoxDecoration(
color: AppColors.blueSoft.withValues(alpha: 0.16),
shape: BoxShape.circle,
),
child: Icon(icon, color: AppColors.blueSoft, size: 16),
),
const SizedBox(height: 5),
Text(
category.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 9,
fontWeight: FontWeight.w600,
height: 1.1,
),
),
],
),
),
),
);
}
}
@@ -0,0 +1,134 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import '../../../core/theme/app_colors.dart';
import '../../../l10n/app_localizations.dart';
/// 剧场页页头:品牌、搜索与个人页入口。
class DiscoverHeader extends StatelessWidget {
const DiscoverHeader({
super.key,
required this.onSearch,
required this.onProfile,
});
final VoidCallback onSearch;
final VoidCallback onProfile;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return SizedBox(
height: 46,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 14),
child: Row(
children: [
_brand(l10n),
const Spacer(),
_HeaderIcon(
key: const Key('discover-search-button'),
icon: Icons.search_rounded,
tooltip: l10n.search,
onTap: onSearch,
),
const SizedBox(width: 8),
_profile(l10n),
],
),
),
);
}
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,
label: l10n.myTab,
child: InkWell(
key: const Key('discover-profile'),
customBorder: const CircleBorder(),
onTap: onProfile,
child: Container(
width: 36,
height: 36,
decoration: const BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF6A5CFF), AppColors.blue],
),
),
child: const Icon(
Icons.person_rounded,
color: AppColors.onSurface,
size: 18,
),
),
),
);
}
}
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),
),
);
}
}
@@ -0,0 +1,68 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import '../../../core/theme/app_colors.dart';
import '../../../l10n/app_localizations.dart';
/// 剧场顶部搜索框。
class DiscoverSearchField extends StatelessWidget {
const DiscoverSearchField({
super.key,
required this.controller,
required this.focusNode,
required this.onChanged,
required this.loading,
});
final TextEditingController controller;
final FocusNode focusNode;
final ValueChanged<String> onChanged;
final bool loading;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 14),
child: SizedBox(
height: 36,
child: TextField(
key: const Key('discover-search'),
controller: controller,
focusNode: focusNode,
onChanged: onChanged,
onTapOutside: (_) => focusNode.unfocus(),
textInputAction: TextInputAction.search,
style: const TextStyle(color: AppColors.onSurface, fontSize: 13),
decoration: InputDecoration(
isDense: true,
filled: true,
fillColor: AppColors.input,
hintText: AppLocalizations.of(context)!.searchDiscover,
hintStyle: const TextStyle(color: AppColors.cream, fontSize: 11),
prefixIcon: const Icon(
Icons.search_rounded,
color: AppColors.subtle,
size: 17,
),
suffixIcon: loading
? const Padding(
padding: EdgeInsets.all(11),
child: CircularProgressIndicator(strokeWidth: 2),
)
: null,
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(20),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(20),
borderSide: const BorderSide(color: AppColors.blue, width: 1),
),
),
),
),
);
}
}
@@ -0,0 +1,65 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import '../../../core/data/models/drama.dart';
import '../../../core/formatting/error_text.dart';
import '../../../core/widgets/empty_state.dart';
import '../../../l10n/app_localizations.dart';
import 'drama_grid.dart';
/// 剧场搜索结果:加载、失败与空结果都留在同一页内。
class DiscoverSearchResults extends StatelessWidget {
const DiscoverSearchResults({
super.key,
required this.loading,
required this.error,
required this.results,
required this.onRetry,
required this.onOpen,
});
final bool loading;
final String? error;
final List<Drama> results;
final VoidCallback onRetry;
final ValueChanged<Drama> onOpen;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Padding(
padding: const EdgeInsets.only(top: 12),
child: _content(l10n),
);
}
Widget _content(AppLocalizations l10n) {
if (loading) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 80),
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
);
}
final message = error;
if (message != null) {
return EmptyState(
key: const Key('discover-search-error'),
icon: Icons.cloud_off_rounded,
title: errorText(l10n, message, l10n.searchFailed),
actionLabel: l10n.retry,
actionKey: const Key('discover-search-retry'),
onAction: onRetry,
);
}
if (results.isEmpty) {
return EmptyState(
key: const Key('discover-search-empty'),
icon: Icons.search_off_rounded,
title: l10n.noSearchResults,
subtitle: l10n.emptyHint,
);
}
return DramaGrid(l10n: l10n, dramas: results, onOpen: onOpen);
}
}
+187
View File
@@ -0,0 +1,187 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import '../../../core/config/app_config.dart';
import '../../../core/data/models/drama.dart';
import '../../../core/theme/app_colors.dart';
import '../../../l10n/app_localizations.dart';
const int _columns = 2;
const double _columnGap = 10;
const double _cardGap = 10;
/// 剧场/分类页共用的双列剧目网格。
class DramaGrid extends StatelessWidget {
const DramaGrid({
super.key,
required this.l10n,
required this.dramas,
required this.onOpen,
});
final AppLocalizations l10n;
final List<Drama> dramas;
final ValueChanged<Drama> onOpen;
@override
Widget build(BuildContext context) {
return Padding(
key: const Key('home-all-dramas-section'),
padding: const EdgeInsets.symmetric(horizontal: 14),
child: Row(
key: const Key('home-all-dramas-grid'),
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: _column(0)),
const SizedBox(width: _columnGap),
Expanded(child: _column(1)),
],
),
);
}
Widget _column(int start) {
final children = <Widget>[];
for (var index = start; index < dramas.length; index += _columns) {
final drama = dramas[index];
children.add(
DramaGridCard(l10n: l10n, drama: drama, onTap: () => onOpen(drama)),
);
if (index + _columns < dramas.length) {
children.add(const SizedBox(height: _cardGap));
}
}
return Column(children: children);
}
}
/// 双列网格中的剧目卡片。
class DramaGridCard extends StatelessWidget {
const DramaGridCard({
super.key,
required this.l10n,
required this.drama,
required this.onTap,
});
final AppLocalizations l10n;
final Drama drama;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return InkWell(
key: Key('home-drama-card-${drama.id}'),
onTap: onTap,
borderRadius: BorderRadius.circular(10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_cover(),
const SizedBox(height: 5),
_title(),
const SizedBox(height: 2),
_genre(),
],
),
);
}
Widget _cover() {
final free =
drama.freeEpisodes > 0 ||
drama.episodes.any((episode) => episode.isFree);
return ClipRRect(
key: Key('home-drama-cover-${drama.id}'),
borderRadius: BorderRadius.circular(10),
child: Stack(
children: [
_DiscoverCoverImage(path: drama.posterUrl),
if (free) Positioned(top: 6, left: 6, child: _freeBadge()),
],
),
);
}
Widget _freeBadge() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: AppColors.blue,
borderRadius: BorderRadius.circular(6),
),
child: Text(
l10n.free,
style: const TextStyle(
color: AppColors.onPrimary,
fontSize: 8,
fontWeight: FontWeight.w800,
),
),
);
}
Widget _title() {
return Text(
drama.title,
key: Key('home-drama-title-${drama.id}'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 11,
fontWeight: FontWeight.w800,
),
);
}
Widget _genre() {
final genre = drama.genre.isEmpty ? drama.category : drama.genre;
return Text(
genre.isEmpty ? l10n.videoPending : genre,
key: Key('home-drama-genre-${drama.id}'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Color(0xFFBBBBBB), fontSize: 9),
);
}
}
class _DiscoverCoverImage extends StatelessWidget {
const _DiscoverCoverImage({required this.path});
final String path;
@override
Widget build(BuildContext context) {
final url = AppConfig.mediaUrl(path);
if (url.isEmpty) return _placeholder();
return Image.network(
url,
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,
),
),
),
);
}
}
@@ -1,258 +0,0 @@
// Author: fruit.com.cn
part of '../discover_view.dart';
class _HomeDramaGrid extends StatelessWidget {
const _HomeDramaGrid({
required this.l10n,
required this.categories,
required this.dramas,
required this.searching,
required this.searchLoading,
required this.searchError,
required this.onRetrySearch,
required this.onOpen,
});
final AppLocalizations l10n;
final CategoryController categories;
final List<Drama> dramas;
final bool searching;
final bool searchLoading;
final String? searchError;
final VoidCallback onRetrySearch;
final ValueChanged<Drama> onOpen;
@override
Widget build(BuildContext context) {
final loading = _loadingState();
if (loading != null) return loading;
final search = _searchState();
if (search != null) return search;
if (dramas.isEmpty) return _emptyState();
return SliverMainAxisGroup(
slivers: [
SliverToBoxAdapter(child: _masonry(dramas)),
const SliverToBoxAdapter(child: SizedBox(height: 24)),
],
);
}
Widget? _loadingState() {
if (categories.loading && categories.feed.sections.isEmpty) {
return const SliverFillRemaining(
hasScrollBody: false,
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
);
}
if (categories.error == null) return null;
return _fill(
EmptyState(
key: const Key('home-categories-error'),
icon: Icons.cloud_off_rounded,
title: l10n.categoriesError,
subtitle: l10n.emptyHint,
actionLabel: l10n.retry,
onAction: categories.load,
),
);
}
Widget? _searchState() {
if (searching && searchLoading) {
return const SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: CircularProgressIndicator(
key: Key('discover-search-loading'),
strokeWidth: 2,
),
),
);
}
if (!searching || searchError == null) return null;
return _fill(
EmptyState(
key: const Key('discover-search-error'),
icon: Icons.cloud_off_rounded,
title: errorText(l10n, searchError, l10n.searchFailed),
actionLabel: l10n.retry,
actionKey: const Key('discover-search-retry'),
onAction: onRetrySearch,
),
);
}
Widget _emptyState() {
return _fill(
EmptyState(
key: const Key('home-all-dramas-empty'),
icon: searching
? Icons.search_off_rounded
: Icons.movie_filter_outlined,
title: searching
? l10n.noSearchResults
: categories.selectedSectionMissing
? l10n.noCategoryDramas
: l10n.noSearchResults,
subtitle: l10n.emptyHint,
),
);
}
Widget _masonry(List<Drama> items) {
return Padding(
key: const Key('home-all-dramas-section'),
padding: const EdgeInsets.symmetric(horizontal: 14),
child: Row(
key: const Key('home-all-dramas-grid'),
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: _column(items, 0)),
const SizedBox(width: 10),
Expanded(child: _column(items, 1)),
],
),
);
}
Widget _column(List<Drama> items, int start) {
final children = <Widget>[];
for (var index = start; index < items.length; index += 2) {
children.add(
_HomeDramaCard(
l10n: l10n,
drama: items[index],
onTap: () => onOpen(items[index]),
),
);
if (index + 2 < items.length) {
children.add(const SizedBox(height: 10));
}
}
return Column(children: children);
}
Widget _fill(Widget child) =>
SliverFillRemaining(hasScrollBody: false, child: child);
}
class _HomeDramaCard extends StatelessWidget {
const _HomeDramaCard({
required this.l10n,
required this.drama,
required this.onTap,
});
final AppLocalizations l10n;
final Drama drama;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final genre = drama.genre.isEmpty ? drama.category : drama.genre;
final free =
drama.freeEpisodes > 0 ||
drama.episodes.any((episode) => episode.isFree);
return InkWell(
key: Key('home-drama-card-${drama.id}'),
onTap: onTap,
borderRadius: BorderRadius.circular(10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
key: Key('home-drama-cover-${drama.id}'),
borderRadius: BorderRadius.circular(10),
child: Stack(
children: [
_DiscoverCoverImage(path: drama.posterUrl),
if (free)
Positioned(
top: 6,
left: 6,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: AppColors.blue,
borderRadius: BorderRadius.circular(6),
),
child: Text(
l10n.free,
style: const TextStyle(
color: AppColors.onPrimary,
fontSize: 8,
fontWeight: FontWeight.w800,
),
),
),
),
],
),
),
const SizedBox(height: 5),
Text(
drama.title,
key: Key('home-drama-title-${drama.id}'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 11,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 2),
Text(
genre.isEmpty ? l10n.videoPending : genre,
key: Key('home-drama-genre-${drama.id}'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Color(0xFFBBBBBB), fontSize: 9),
),
],
),
);
}
}
class _DiscoverCoverImage extends StatelessWidget {
const _DiscoverCoverImage({required this.path});
final String path;
@override
Widget build(BuildContext context) {
final url = AppConfig.mediaUrl(path);
if (url.isEmpty) return _placeholder();
return Image.network(
url,
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,
),
),
),
);
}
}
@@ -0,0 +1,268 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import '../../../core/data/models/drama.dart';
import '../../../core/data/models/home_feed.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/widgets/empty_state.dart';
import '../../../core/widgets/remote_image.dart';
import '../../../l10n/app_localizations.dart';
import '../ranking_logic.dart';
const int _topLimit = 5;
const double _rowHeight = 68;
const double _posterWidth = 40;
const double _posterHeight = 54;
const double _badgeSize = 24;
/// 发现页内嵌榜单区块:热播/新剧切换 + 前 5 名。
class RankingSection extends StatefulWidget {
const RankingSection({
super.key,
required this.sections,
required this.fallback,
required this.onOpen,
});
final List<FeedSection> sections;
final List<Drama> fallback;
final ValueChanged<Drama> onOpen;
@override
State<RankingSection> createState() => _RankingSectionState();
}
class _RankingSectionState extends State<RankingSection> {
late final RankingLogic logic = RankingLogic(
sections: widget.sections,
fallback: widget.fallback,
);
@override
void didUpdateWidget(covariant RankingSection oldWidget) {
super.didUpdateWidget(oldWidget);
if (!identical(oldWidget.sections, widget.sections) ||
!identical(oldWidget.fallback, widget.fallback)) {
logic.updateSources(sections: widget.sections, fallback: widget.fallback);
}
}
@override
void dispose() {
logic.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => ListenableBuilder(
listenable: logic,
builder: (context, _) => _content(context),
);
Widget _content(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final entries = logic.entries.take(_topLimit).toList();
return Container(
key: const Key('discover-ranking-section'),
margin: const EdgeInsets.fromLTRB(14, 16, 14, 8),
padding: const EdgeInsets.fromLTRB(12, 12, 12, 8),
decoration: BoxDecoration(
color: AppColors.card,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColors.border),
),
child: Column(
children: [
_header(l10n),
const SizedBox(height: 8),
if (entries.isEmpty)
EmptyState(
key: const Key('ranking-empty'),
icon: Icons.leaderboard_outlined,
title: l10n.rankingEmptyTitle,
subtitle: l10n.rankingEmptyBody,
compact: true,
)
else
for (final entry in entries) _row(entry, l10n),
],
),
);
}
Widget _header(AppLocalizations l10n) {
return Row(
children: [
const Icon(
Icons.local_fire_department_rounded,
color: AppColors.blue,
size: 18,
),
const SizedBox(width: 6),
Expanded(
child: Text(
l10n.rankingTitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 13,
fontWeight: FontWeight.w800,
),
),
),
_boardChip(
key: const Key('ranking-tab-hot'),
label: l10n.rankingHot,
board: RankingBoard.hot,
),
const SizedBox(width: 6),
_boardChip(
key: const Key('ranking-tab-new'),
label: l10n.rankingNew,
board: RankingBoard.newReleases,
),
],
);
}
Widget _boardChip({
required Key key,
required String label,
required RankingBoard board,
}) {
final selected = logic.board == board;
return Material(
key: key,
color: selected ? AppColors.blue : AppColors.raised,
borderRadius: BorderRadius.circular(999),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () => logic.select(board),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 5),
child: Text(
label,
style: TextStyle(
color: selected ? AppColors.onPrimary : AppColors.muted,
fontSize: 9,
fontWeight: FontWeight.w700,
),
),
),
),
);
}
Widget _row(RankingEntry entry, AppLocalizations l10n) {
return _RankRow(
entry: entry,
subtitle: _subtitle(entry.drama, l10n),
onTap: () => widget.onOpen(entry.drama),
);
}
String _subtitle(Drama drama, AppLocalizations l10n) {
final genre = drama.genre.isNotEmpty ? drama.genre : drama.category;
final count = l10n.episodeCount(drama.episodeCount);
return genre.isEmpty ? count : '$genre · $count';
}
}
class _RankRow extends StatelessWidget {
const _RankRow({
required this.entry,
required this.subtitle,
required this.onTap,
});
final RankingEntry entry;
final String subtitle;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final drama = entry.drama;
return Material(
key: Key('ranking-entry-${drama.id}'),
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(10),
child: SizedBox(height: _rowHeight, child: _content(drama)),
),
);
}
Widget _content(Drama drama) {
return Row(
children: [
_rankBadge(entry.rank),
const SizedBox(width: 8),
ClipRRect(
borderRadius: BorderRadius.circular(7),
child: RemoteImage(
drama.posterUrl,
width: _posterWidth,
height: _posterHeight,
),
),
const SizedBox(width: 10),
Expanded(child: _text(drama)),
const Icon(
Icons.chevron_right_rounded,
color: AppColors.subtle,
size: 18,
),
],
);
}
Widget _text(Drama drama) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
drama.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 11,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 3),
Text(
subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: AppColors.subtle, fontSize: 9),
),
],
);
}
Widget _rankBadge(int rank) {
final top = rank <= 3;
return Container(
width: _badgeSize,
height: _badgeSize,
alignment: Alignment.center,
decoration: BoxDecoration(
color: top ? AppColors.gold.withValues(alpha: 0.16) : AppColors.raised,
borderRadius: BorderRadius.circular(7),
),
child: Text(
'$rank',
style: TextStyle(
color: top ? AppColors.gold : AppColors.muted,
fontSize: 11,
fontWeight: FontWeight.w900,
),
),
);
}
}
+19 -53
View File
@@ -7,12 +7,12 @@ 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/data/repositories/catalog_repository.dart';
import '../../l10n/app_localizations.dart';
import '../discover/discover_logic.dart';
import '../discover/discover_types.dart';
import '../drama_detail/drama_detail_view.dart';
import '../mine/mine_logic.dart';
import '../favorites/favorites_page.dart';
import '../mine/mine_types.dart';
import '../player/player_view.dart';
import '../watch_records/watch_records_page.dart';
class HomeLogic extends ChangeNotifier {
HomeLogic(List<Drama> dramas) : currentDrama = dramas.first;
@@ -21,34 +21,21 @@ class HomeLogic extends ChangeNotifier {
Drama currentDrama;
int tab = 0;
int searchRequest = 0;
int sectionRequest = 0;
int resumeRequest = 0;
int backRequest = 0;
int mySection = 0;
bool drawerOpen = false;
bool detailOpen = false;
bool videoImmersive = false;
bool profile = false;
bool _disposed = false;
void notify() {
if (!_disposed) notifyListeners();
}
void select(
int nextTab, {
bool search = false,
bool watchlist = false,
bool resume = false,
int? mySection,
}) {
void select(int nextTab, {bool search = false, bool resume = false}) {
FocusManager.instance.primaryFocus?.unfocus();
tab = nextTab;
if (search) searchRequest++;
if (watchlist || mySection != null) {
this.mySection = mySection ?? 1;
sectionRequest++;
}
if (resume) resumeRequest++;
notify();
}
@@ -59,11 +46,6 @@ class HomeLogic extends ChangeNotifier {
notify();
}
void selectFromMenu(int nextTab, {int? mySection}) {
drawerOpen = false;
select(nextTab, mySection: mySection);
}
Future<void> openCurrentSeries(BuildContext context) async {
detailOpen = true;
notify();
@@ -87,20 +69,6 @@ class HomeLogic extends ChangeNotifier {
);
}
/// Resolves a banner link that is not in the loaded feed, then opens detail.
Future<void> openDramaById(BuildContext context, String dramaId) async {
try {
final drama = await context.read<CatalogRepository>().fetchDrama(dramaId);
if (context.mounted) openDrama(context, drama);
} catch (_) {
if (!context.mounted) return;
final message = AppLocalizations.of(context)!.detailLoadFailed;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}
}
Future<void> playDrama(BuildContext context, Drama drama) async {
// 列表卡片只有一集预览,进入播放前补齐完整剧集,否则选集列表是空的。
var playable = drama;
@@ -122,31 +90,29 @@ class HomeLogic extends ChangeNotifier {
);
}
void openProfile() {
profile = true;
select(2, mySection: 0);
}
void openProfile() => select(2);
void openLibrary({int section = 1}) {
profile = false;
select(2, mySection: section);
}
void discoverAction(BuildContext context, DiscoverPageAction action) {
void discoverAction(DiscoverPageAction action) {
switch (action) {
case DiscoverPageAction.home:
select(0);
case DiscoverPageAction.my:
select(2);
case DiscoverPageAction.watchlist:
openLibrary();
case DiscoverPageAction.resume:
select(0, resume: true);
case DiscoverPageAction.detail:
openDrama(context, currentDrama);
}
}
void openFavorites(BuildContext context) {
Navigator.of(
context,
).push(MaterialPageRoute<void>(builder: (_) => const FavoritesPage()));
}
void openHistory(BuildContext context) {
Navigator.of(
context,
).push(MaterialPageRoute<void>(builder: (_) => const WatchRecordsPage()));
}
void myAction(MyPageAction action) {
switch (action) {
case MyPageAction.home:
+105 -132
View File
@@ -6,8 +6,6 @@ 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/services/auth_controller.dart';
import '../../core/data/services/catalog_controller.dart';
import '../../core/data/services/download_controller.dart';
import '../../core/data/services/wallet_controller.dart';
@@ -15,14 +13,14 @@ import '../../core/platform/haptics.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_theme.dart';
import '../../core/widgets/app_bottom_nav.dart';
import '../../core/widgets/auth_gate.dart';
import '../../core/widgets/empty_state.dart';
import '../../l10n/app_localizations.dart';
import '../discover/discover_types.dart';
import '../discover/discover_view.dart';
import '../mine/mine_types.dart';
import '../splash/splash_page.dart';
import '../mine/mine_view.dart';
import '../player/player_view.dart';
import '../ranking/ranking_view.dart';
import 'home_binding.dart';
import 'home_logic.dart';
import 'widgets/home_menu_sheet.dart';
@@ -75,8 +73,6 @@ class HomePage extends StatelessWidget {
return _HomeTabs(
key: ValueKey(catalog.dramas.first.id),
dramas: catalog.dramas,
sections: catalog.sections,
banners: catalog.banners,
);
}
}
@@ -101,16 +97,9 @@ class HomeFailureView extends StatelessWidget {
/// The three primary destinations share a single route and keep their state.
class _HomeTabs extends StatefulWidget {
const _HomeTabs({
super.key,
required this.dramas,
required this.sections,
required this.banners,
});
const _HomeTabs({super.key, required this.dramas});
final List<Drama> dramas;
final List<FeedSection> sections;
final List<FeedBanner> banners;
@override
State<_HomeTabs> createState() => _HomeTabsState();
@@ -141,8 +130,6 @@ class _HomeTabsState extends State<_HomeTabs> {
Widget _scaffold(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
// 登录态统一从 AuthController 取(原来是 home_logic 里的本地演示开关)
final signedIn = context.watch<AuthController>().isSignedIn;
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, _) {
@@ -156,126 +143,113 @@ class _HomeTabsState extends State<_HomeTabs> {
child: Scaffold(
key: logic.scaffoldKey,
backgroundColor: AppColors.ink,
body: IndexedStack(
index: logic.tab,
children: [
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,
onSearch: () => logic.select(1, search: true),
onMenu: () => _openMenu(context),
onImmersiveChanged: logic.onImmersiveChanged,
wallet: context.read<WalletController>(),
downloads: context.read<DownloadController>(),
),
DiscoverPage(
drama: logic.currentDrama,
dramas: widget.dramas,
onDramaSelected: (drama) => logic.playDrama(context, drama),
duration: Duration.zero,
embedded: true,
active: logic.tab == 1,
searchRequest: logic.searchRequest,
signedIn: signedIn,
onSignIn: () => openAuthPage(context),
onCoins: logic.openProfile,
onRewards: logic.openProfile,
banners: widget.banners,
onBannerSelected: (banner) {
final dramaId = banner.dramaId;
if (dramaId != null) {
unawaited(logic.openDramaById(context, dramaId));
}
},
onAction: (action) => logic.discoverAction(context, action),
),
MyPage(
drama: logic.currentDrama,
dramas: widget.dramas,
onDramaSelected: (drama) => logic.openDrama(context, drama),
duration: Duration.zero,
initialSection: logic.mySection,
profile: logic.profile,
sectionRequest: logic.sectionRequest,
embedded: true,
active: logic.tab == 2,
onAction: logic.myAction,
signedIn: signedIn,
onSignIn: () => openAuthPage(context),
onSignOut: () => context.read<AuthController>().signOut(),
wallet: context.read<WalletController>(),
),
RankingPage(
sections: widget.sections,
fallback: widget.dramas,
onOpen: (drama) => logic.openDrama(context, drama),
embedded: true,
active: logic.tab == 3,
),
],
),
bottomNavigationBar: logic.videoImmersive
? null
: AppBottomNav(
barKey: const Key('home-tabs-bar'),
items: [
AppBottomNavItem(
tabKey: const Key('tab-home'),
icon: Icons.play_circle_outline_rounded,
selectedIcon: Icons.play_circle_fill_rounded,
label: l10n.homeTab,
selected: logic.tab == 0,
onTap: () => _selectTab(context, 0),
),
AppBottomNavItem(
tabKey: const Key('tab-discover'),
icon: Icons.explore_outlined,
selectedIcon: Icons.explore_rounded,
label: l10n.discoverTab,
selected: logic.tab == 1,
onTap: () => _selectTab(context, 1),
),
AppBottomNavItem(
tabKey: const Key('tab-ranking'),
icon: Icons.leaderboard_outlined,
selectedIcon: Icons.leaderboard_rounded,
label: l10n.rankingTab,
selected: logic.tab == 3,
onTap: () => _selectTab(context, 3),
),
AppBottomNavItem(
tabKey: const Key('tab-my'),
icon: Icons.video_library_outlined,
selectedIcon: Icons.video_library_rounded,
label: l10n.myTab,
selected: logic.tab == 2,
onTap: () => _selectTab(context, 2, myProfile: true),
),
],
),
body: _body(context),
bottomNavigationBar: logic.videoImmersive ? null : _nav(context, l10n),
),
);
}
void _selectTab(BuildContext context, int tab, {bool myProfile = false}) {
Widget _body(BuildContext context) {
return IndexedStack(
index: logic.tab,
children: [_player(context), _discover(context), _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,
onSearch: () => logic.select(1, search: true),
onMenu: () => _openMenu(context),
onImmersiveChanged: logic.onImmersiveChanged,
wallet: context.read<WalletController>(),
downloads: context.read<DownloadController>(),
);
}
Widget _discover(BuildContext context) {
return DiscoverPage(
active: logic.tab == 1,
searchRequest: logic.searchRequest,
callbacks: DiscoverPageCallbacks(
onAction: logic.discoverAction,
onDramaSelected: (drama) => logic.playDrama(context, drama),
onProfile: logic.openProfile,
),
);
}
Widget _my(BuildContext context) {
return MyPage(
active: logic.tab == 2,
callbacks: MyPageCallbacks(
onAction: logic.myAction,
onDramaSelected: (drama) => logic.openDrama(context, drama),
),
);
}
Widget _nav(BuildContext context, AppLocalizations l10n) {
return AppBottomNav(
barKey: const Key('home-tabs-bar'),
items: [
_navItem(context, l10n, 0),
_navItem(context, l10n, 1),
_navItem(context, l10n, 2),
],
);
}
AppBottomNavItem _navItem(
BuildContext context,
AppLocalizations l10n,
int index,
) {
final selected = logic.tab == index;
return switch (index) {
0 => AppBottomNavItem(
tabKey: const Key('tab-home'),
icon: Icons.play_circle_outline_rounded,
selectedIcon: Icons.play_circle_fill_rounded,
label: l10n.homeTab,
selected: selected,
onTap: () => _selectTab(context, index),
),
1 => AppBottomNavItem(
tabKey: const Key('tab-discover'),
icon: Icons.explore_outlined,
selectedIcon: Icons.explore_rounded,
label: l10n.discoverTab,
selected: selected,
onTap: () => _selectTab(context, index),
),
_ => AppBottomNavItem(
tabKey: const Key('tab-my'),
icon: Icons.video_library_outlined,
selectedIcon: Icons.video_library_rounded,
label: l10n.myTab,
selected: selected,
onTap: () => _selectTab(context, index),
),
};
}
void _selectTab(BuildContext context, int tab) {
final same = logic.tab == tab;
if (!same) {
unawaited(context.read<Haptics>().selectionClick());
}
if (tab == 2) {
myProfile ? logic.openProfile() : logic.openLibrary();
} else {
logic.select(tab);
}
logic.select(tab);
}
Future<void> _openMenu(BuildContext context) async {
@@ -288,10 +262,9 @@ class _HomeTabsState extends State<_HomeTabs> {
builder: (_) => HomeMenuSheet(
drama: logic.currentDrama,
onOpenSeries: () => logic.openCurrentSeries(context),
onDiscover: () => logic.selectFromMenu(1),
onHistory: () => logic.openLibrary(section: 0),
onWatchlist: logic.openLibrary,
onLiked: () => logic.selectFromMenu(2, mySection: 2),
onDiscover: () => logic.select(1),
onHistory: () => logic.openHistory(context),
onWatchlist: () => logic.openFavorites(context),
),
);
logic.setMenuOpen(false);
+135 -153
View File
@@ -1,16 +1,14 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../../core/config/locale_controller.dart';
import '../../../core/data/models/drama.dart';
import '../../../core/formatting/localized_content.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/widgets/remote_image.dart';
import '../../../l10n/app_localizations.dart';
/// Compact navigation menu opened from the preview home header.
/// 播放页快捷菜单:当前剧、发现、追剧与历史。
class HomeMenuSheet extends StatelessWidget {
const HomeMenuSheet({
super.key,
@@ -19,7 +17,6 @@ class HomeMenuSheet extends StatelessWidget {
required this.onDiscover,
required this.onHistory,
required this.onWatchlist,
required this.onLiked,
});
final Drama drama;
@@ -27,12 +24,10 @@ class HomeMenuSheet extends StatelessWidget {
final VoidCallback onDiscover;
final VoidCallback onHistory;
final VoidCallback onWatchlist;
final VoidCallback onLiked;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final languageCode = Localizations.localeOf(context).languageCode;
void closeThen(VoidCallback action) {
Navigator.pop(context);
action();
@@ -45,160 +40,147 @@ class HomeMenuSheet extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: AppColors.cardAlt,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(10),
child: RemoteImage(drama.posterUrl, width: 64, height: 86),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
localizedDramaTitle(l10n, drama),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 15,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 4),
Text(
l10n.episodeCount(drama.episodeCount),
style: const TextStyle(
color: AppColors.muted,
fontSize: 9,
),
),
const SizedBox(height: 8),
SizedBox(
height: 34,
child: FilledButton.icon(
key: const Key('drawer-open-series'),
onPressed: () => closeThen(onOpenSeries),
style: FilledButton.styleFrom(
backgroundColor: AppColors.blue,
foregroundColor: AppColors.onPrimary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(9),
),
textStyle: const TextStyle(fontSize: 11),
),
icon: const Icon(
Icons.play_arrow_rounded,
size: 15,
),
label: Text(l10n.exploreSeries),
),
),
],
),
),
],
),
_DramaSummary(
drama: drama,
l10n: l10n,
onOpenSeries: () => closeThen(onOpenSeries),
),
const SizedBox(height: 14),
Text(
l10n.quickAccess,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 13,
fontWeight: FontWeight.w800,
),
_quickAccess(l10n, closeThen),
],
),
),
);
}
Widget _quickAccess(
AppLocalizations l10n,
void Function(VoidCallback) closeThen,
) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.quickAccess,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 13,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 8),
Row(
children: [
_item(
key: const Key('drawer-discover'),
icon: Icons.explore_outlined,
label: l10n.discoverTab,
onTap: () => closeThen(onDiscover),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: _MenuTile(
key: const Key('drawer-discover'),
icon: Icons.explore_outlined,
label: l10n.discoverTab,
onTap: () => closeThen(onDiscover),
),
),
const SizedBox(width: 8),
Expanded(
child: _MenuTile(
key: const Key('drawer-history'),
icon: Icons.history_rounded,
label: l10n.history,
onTap: () => closeThen(onHistory),
),
),
],
_item(
key: const Key('drawer-watchlist'),
icon: Icons.bookmark_border_rounded,
label: l10n.watchlist,
onTap: () => closeThen(onWatchlist),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: _MenuTile(
key: const Key('drawer-watchlist'),
icon: Icons.bookmark_border_rounded,
label: l10n.watchlist,
onTap: () => closeThen(onWatchlist),
),
),
const SizedBox(width: 8),
Expanded(
child: _MenuTile(
key: const Key('drawer-liked'),
icon: Icons.favorite_border_rounded,
label: l10n.likedTab,
onTap: () => closeThen(onLiked),
),
),
],
),
const SizedBox(height: 14),
Text(
l10n.appLanguage,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 13,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final (code, label) in [
('en', l10n.english),
('zh', l10n.simplifiedChinese),
('id', l10n.bahasaIndonesia),
])
ChoiceChip(
label: Text(label),
selected: languageCode == code,
onSelected: (_) =>
context.read<LocaleController>().select(code),
backgroundColor: AppColors.input,
selectedColor: AppColors.blue,
labelStyle: TextStyle(
color: languageCode == code
? AppColors.onPrimary
: AppColors.muted,
fontSize: 10,
),
side: BorderSide.none,
showCheckmark: false,
),
],
_item(
key: const Key('drawer-history'),
icon: Icons.history_rounded,
label: l10n.history,
onTap: () => closeThen(onHistory),
),
],
),
],
);
}
Widget _item({
required Key key,
required IconData icon,
required String label,
required VoidCallback onTap,
}) {
return Expanded(
child: Padding(
padding: const EdgeInsets.only(right: 8),
child: _MenuTile(key: key, icon: icon, label: label, onTap: onTap),
),
);
}
}
class _DramaSummary extends StatelessWidget {
const _DramaSummary({
required this.drama,
required this.l10n,
required this.onOpenSeries,
});
final Drama drama;
final AppLocalizations l10n;
final VoidCallback onOpenSeries;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: AppColors.cardAlt,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(10),
child: RemoteImage(drama.posterUrl, width: 64, height: 86),
),
const SizedBox(width: 10),
Expanded(child: _details()),
],
),
);
}
Widget _details() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
localizedDramaTitle(l10n, drama),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 15,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 4),
Text(
l10n.episodeCount(drama.episodeCount),
style: const TextStyle(color: AppColors.muted, fontSize: 9),
),
const SizedBox(height: 8),
_button(),
],
);
}
Widget _button() {
return SizedBox(
height: 34,
child: FilledButton.icon(
key: const Key('drawer-open-series'),
onPressed: onOpenSeries,
style: FilledButton.styleFrom(
backgroundColor: AppColors.blue,
foregroundColor: AppColors.onPrimary,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(9)),
textStyle: const TextStyle(fontSize: 11),
),
icon: const Icon(Icons.play_arrow_rounded, size: 15),
label: Text(l10n.exploreSeries),
),
);
}
+3 -10
View File
@@ -1,17 +1,10 @@
// Author: fruit.com.cn
import '../../core/data/models/drama.dart';
import '../../core/data/repositories/watch_history_repository.dart';
import 'mine_logic.dart';
/// 组装「我的」页状态。
class MineBinding {
MineLogic create({
required Drama drama,
required int initialSection,
required WatchHistoryRepository history,
}) => MineLogic(
drama: drama,
initialSection: initialSection,
history: history,
);
MineLogic create({required WatchHistoryRepository history}) =>
MineLogic(history: history);
}
+8 -102
View File
@@ -1,59 +1,28 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../core/config/locale_controller.dart';
import '../../core/data/models/drama.dart';
import '../../core/data/models/watch_progress.dart';
import '../../core/data/repositories/watch_history_repository.dart';
import '../../core/theme/app_colors.dart';
import '../../l10n/app_localizations.dart';
enum MyPageAction { home, discover, resume }
import 'mine_types.dart';
/// 「我的」页状态:最近观看与根导航动作。
class MineLogic extends ChangeNotifier {
MineLogic({
required this.history,
required this.drama,
required int initialSection,
}) : section = initialSection {
refreshPosition();
MineLogic({required this.history}) {
refreshRecent();
}
final WatchHistoryRepository history;
Drama drama;
late Future<Duration?> position;
List<WatchProgress> recent = const [];
bool recentLoading = false;
String? recentError;
/// 续看位置读取失败:与"没有记录"区分开,失败要能重试(不能假装成从 0 开始)
bool positionFailed = false;
int section;
int filter = 0;
bool _disposed = false;
void notify() {
if (!_disposed) notifyListeners();
}
void refreshPosition() {
position = history
.resumePointFor(drama.id)
.then((point) {
positionFailed = false;
return point?.position;
})
.catchError((Object _) {
positionFailed = true;
notify();
return null;
});
refreshRecent();
}
/// 最近观看:服务端只给 dramaId/位置,剧名封面用已加载的目录卡片按 id 映射。
/// 最近观看:服务端只给 dramaId/位置,剧名封面用目录卡片按 id 映射。
Future<void> refreshRecent() async {
recentLoading = true;
recentError = null;
@@ -68,34 +37,9 @@ class MineLogic extends ChangeNotifier {
}
}
void selectSection(int index) {
section = index;
notify();
}
void selectFilter(int index) {
filter = index;
notify();
}
void syncPage({
required bool active,
required bool wasActive,
required Drama nextDrama,
required bool dramaChanged,
required int sectionRequest,
required int previousSectionRequest,
required int initialSection,
}) {
if (active && (!wasActive || dramaChanged)) {
drama = nextDrama;
refreshPosition();
notify();
}
if (sectionRequest != previousSectionRequest) {
section = initialSection;
notify();
}
/// 页面重新可见时刷新最近观看,避免停留在旧进度。
void syncPage({required bool active, required bool wasActive}) {
if (active && !wasActive) refreshRecent();
}
void act(
@@ -111,44 +55,6 @@ class MineLogic extends ChangeNotifier {
}
}
Future<void> showLanguage(BuildContext context) async {
final l10n = AppLocalizations.of(context)!;
await showModalBottomSheet<void>(
context: context,
backgroundColor: AppColors.ink,
showDragHandle: true,
isScrollControlled: true,
builder: (sheetContext) => SafeArea(
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.sizeOf(sheetContext).height * 0.85,
),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(title: Text(l10n.language)),
for (final (code, label) in [
('en', l10n.english),
('zh', l10n.simplifiedChinese),
('id', l10n.bahasaIndonesia),
])
ListTile(
title: Text(label),
onTap: () {
context.read<LocaleController>().select(code);
Navigator.pop(sheetContext);
},
),
const SizedBox(height: 8),
],
),
),
),
),
);
}
@override
void dispose() {
_disposed = true;
+18
View File
@@ -0,0 +1,18 @@
// Author: fruit.com.cn
import 'package:flutter/foundation.dart';
import '../../core/data/models/drama.dart';
/// 「我的」页与根导航之间的返回动作。
enum MyPageAction { home, discover, resume }
/// 「我的」页导航回调;没有 `onAction` 时按独立页面处理。
class MyPageCallbacks {
const MyPageCallbacks({this.onAction, this.onDramaSelected});
final ValueChanged<MyPageAction>? onAction;
final ValueChanged<Drama>? onDramaSelected;
bool get embedded => onAction != null;
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,114 @@
// 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/widgets/remote_image.dart';
import '../../../l10n/app_localizations.dart';
/// 「我的」页唯一保留的续看入口。
class ContinueWatchingCard extends StatelessWidget {
const ContinueWatchingCard({
super.key,
required this.drama,
required this.record,
required this.onResume,
});
final Drama drama;
final WatchProgress record;
final VoidCallback onResume;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Container(
key: const Key('mine-continue-watching'),
margin: const EdgeInsets.symmetric(horizontal: 14),
padding: const EdgeInsets.all(9),
decoration: BoxDecoration(
color: AppColors.card,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(7),
child: RemoteImage(drama.posterUrl, width: 50, height: 64),
),
const SizedBox(width: 9),
Expanded(child: _info(l10n)),
const SizedBox(width: 8),
SizedBox(
width: 60,
height: 28,
child: FilledButton(
key: const Key('mine-continue-play'),
onPressed: onResume,
style: FilledButton.styleFrom(
backgroundColor: AppColors.blue,
foregroundColor: AppColors.onPrimary,
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(9),
),
textStyle: const TextStyle(fontSize: 10),
),
child: Text(l10n.resume),
),
),
],
),
);
}
Widget _info(AppLocalizations l10n) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.continueWatching,
style: const TextStyle(
color: AppColors.accent,
fontSize: 9,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 2),
Text(
drama.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 12,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 2),
Text(
_progressText(l10n),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Color(0xFFBBBBBB), fontSize: 9),
),
],
);
}
String _progressText(AppLocalizations l10n) {
final episode = drama.episodes.where((item) => item.id == record.episodeId);
final label = episode.isEmpty
? l10n.episodeCount(drama.episodeCount)
: l10n.episodeOfTotal(episode.first.number, drama.episodeCount);
return '$label · ${_clock(record.position)} / ${_clock(record.duration)}';
}
}
String _clock(Duration value) {
final minutes = value.inMinutes.remainder(60).toString().padLeft(2, '0');
final seconds = value.inSeconds.remainder(60).toString().padLeft(2, '0');
return '$minutes:$seconds';
}
+84
View File
@@ -0,0 +1,84 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../../core/data/services/auth_controller.dart';
import '../../../core/theme/app_colors.dart';
import '../../../l10n/app_localizations.dart';
/// 「我的」页顶部身份区,只保留头像、昵称和账号提示。
class MineHeader extends StatelessWidget {
const MineHeader({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final auth = context.watch<AuthController>();
final user = auth.user;
final signedIn = auth.isSignedIn;
final name = signedIn && (user?.nickname.isNotEmpty ?? false)
? user!.nickname
: l10n.localViewer;
final subtitle = signedIn && (user?.uid.isNotEmpty ?? false)
? 'ID: ${user!.uid}'
: l10n.logInHint;
return Padding(
key: const Key('mine-user-row'),
padding: const EdgeInsets.fromLTRB(14, 14, 14, 6),
child: Row(
children: [
_avatar(),
const SizedBox(width: 10),
Expanded(child: _labels(name, subtitle)),
],
),
);
}
Widget _avatar() {
return Container(
key: const Key('mine-avatar'),
width: 48,
height: 48,
decoration: const BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF6A5CFF), AppColors.blue],
),
),
child: const Icon(
Icons.person_rounded,
color: AppColors.onSurface,
size: 22,
),
);
}
Widget _labels(String name, String subtitle) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 15,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 2),
Text(
subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Color(0xFFBBBBBB), fontSize: 10),
),
],
);
}
}
+113
View File
@@ -0,0 +1,113 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import '../../../core/theme/app_colors.dart';
import '../../../l10n/app_localizations.dart';
/// 「我的」页六个核心入口。
enum MineDestination { watchlist, history, downloads, wallet, vip, settings }
/// 极简功能清单,避免在主页堆叠内容列表。
class MineMenu extends StatelessWidget {
const MineMenu({super.key, required this.walletValue, required this.onOpen});
final String walletValue;
final ValueChanged<MineDestination> onOpen;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Container(
key: const Key('mine-menu'),
padding: const EdgeInsets.symmetric(vertical: 4),
decoration: BoxDecoration(
color: AppColors.card,
borderRadius: BorderRadius.circular(14),
),
child: Column(children: [..._libraryTiles(l10n), ..._accountTiles(l10n)]),
);
}
List<Widget> _libraryTiles(AppLocalizations l10n) {
return [
_tile(
key: const Key('mine-watchlist'),
icon: Icons.bookmark_border_rounded,
title: l10n.watchlist,
onTap: () => onOpen(MineDestination.watchlist),
),
_tile(
key: const Key('mine-history'),
icon: Icons.history_rounded,
title: l10n.watchHistory,
onTap: () => onOpen(MineDestination.history),
),
_tile(
key: const Key('mine-downloads'),
icon: Icons.download_outlined,
title: l10n.downloads,
onTap: () => onOpen(MineDestination.downloads),
),
];
}
List<Widget> _accountTiles(AppLocalizations l10n) {
return [
_tile(
key: const Key('mine-wallet'),
icon: Icons.monetization_on_outlined,
title: l10n.wallet,
subtitle: walletValue,
onTap: () => onOpen(MineDestination.wallet),
),
_tile(
key: const Key('mine-vip'),
icon: Icons.workspace_premium_outlined,
title: l10n.vipMembership,
onTap: () => onOpen(MineDestination.vip),
),
_tile(
key: const Key('mine-settings'),
icon: Icons.settings_outlined,
title: l10n.settings,
onTap: () => onOpen(MineDestination.settings),
),
];
}
Widget _tile({
required Key key,
required IconData icon,
required String title,
required VoidCallback onTap,
String? subtitle,
}) {
return Material(
color: Colors.transparent,
child: ListTile(
key: key,
dense: true,
minTileHeight: 48,
contentPadding: const EdgeInsets.symmetric(horizontal: 14),
leading: Icon(icon, color: AppColors.blueSoft, size: 19),
title: Text(
title,
style: const TextStyle(color: AppColors.onSurface, fontSize: 12),
),
subtitle: subtitle == null
? null
: Text(
subtitle,
style: const TextStyle(color: AppColors.muted, fontSize: 9),
),
trailing: const Icon(
Icons.chevron_right_rounded,
color: AppColors.subtle,
size: 18,
),
onTap: onTap,
),
);
}
}
+9 -39
View File
@@ -23,10 +23,10 @@ import '../../core/data/services/wallet_controller.dart';
import '../../core/formatting/localized_content.dart';
import '../../core/theme/app_colors.dart';
import '../../l10n/app_localizations.dart';
import '../discover/discover_logic.dart';
import '../discover/discover_types.dart';
import '../discover/discover_view.dart';
import '../drama_detail/drama_detail_view.dart';
import '../mine/mine_logic.dart';
import '../mine/mine_types.dart';
import '../mine/mine_view.dart';
import 'player_view.dart';
import 'widgets/drama_comments_sheet.dart';
@@ -758,7 +758,7 @@ class PlayerLogic extends ChangeNotifier {
);
}
Future<void> showMy(BuildContext context, {int initialSection = 0}) async {
Future<void> showMy(BuildContext context) async {
if (embedded) {
onMy?.call();
return;
@@ -767,20 +767,9 @@ class PlayerLogic extends ChangeNotifier {
await session.playbackSettled;
await saveCurrentProgress();
if (!context.mounted) return;
final port = session.active;
final drama = queue.entryAt(session.currentIndex).drama;
final duration = port is VideoPlayerPort
? port.controller.value.duration
: Duration.zero;
final action = await Navigator.of(context).push<MyPageAction>(
MaterialPageRoute(
builder: (_) => MyPage(
drama: drama,
duration: duration,
initialSection: initialSection,
),
),
);
final action = await Navigator.of(
context,
).push<MyPageAction>(MaterialPageRoute(builder: (_) => const MyPage()));
if (!context.mounted) return;
if (action == MyPageAction.discover) {
await showDiscover(context);
@@ -810,33 +799,14 @@ class PlayerLogic extends ChangeNotifier {
await session.playbackSettled;
await saveCurrentProgress();
if (!context.mounted) return;
final port = session.active;
final duration = port is VideoPlayerPort
? port.controller.value.duration
: Duration.zero;
final action = await Navigator.of(context).push<DiscoverPageAction>(
MaterialPageRoute(
builder: (_) => DiscoverPage(
drama: pageDrama,
duration: duration,
focusSearch: focusSearch,
),
),
MaterialPageRoute(builder: (_) => DiscoverPage(focusSearch: focusSearch)),
);
if (!context.mounted) return;
if (action == DiscoverPageAction.detail) {
await openDetail(context);
} else if (action == DiscoverPageAction.my ||
action == DiscoverPageAction.watchlist) {
await showMy(
context,
initialSection: action == DiscoverPageAction.watchlist ? 1 : 0,
);
if (action == DiscoverPageAction.my) {
await showMy(context);
} else {
session.setRouteVisible(true);
if (action == DiscoverPageAction.resume) {
session.setUserWantsPlay(true);
}
}
}
-443
View File
@@ -1,443 +0,0 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import '../../core/data/models/drama.dart';
import '../../core/data/models/home_feed.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_dimens.dart';
import '../../core/theme/app_theme.dart';
import '../../core/widgets/empty_state.dart';
import '../../core/widgets/remote_image.dart';
import '../../l10n/app_localizations.dart';
import 'ranking_logic.dart';
const double _pageHeaderHeight = 62;
const double _selectorHeight = 48;
const double _championHeight = 214;
const double _posterWidth = 62;
const double _posterHeight = 84;
const double _rankWidth = 34;
const int _topThreeLimit = 3;
/// Full-screen charts page driven by the real `hot` and `new` feed sections.
class RankingPage extends StatefulWidget {
const RankingPage({
super.key,
required this.sections,
required this.fallback,
required this.onOpen,
this.embedded = false,
this.active = true,
});
final List<FeedSection> sections;
final List<Drama> fallback;
final ValueChanged<Drama> onOpen;
final bool embedded;
final bool active;
@override
State<RankingPage> createState() => _RankingPageState();
}
class _RankingPageState extends State<RankingPage> {
late final RankingLogic logic = RankingLogic(
sections: widget.sections,
fallback: widget.fallback,
);
@override
void didUpdateWidget(covariant RankingPage oldWidget) {
super.didUpdateWidget(oldWidget);
if (!identical(oldWidget.sections, widget.sections) ||
!identical(oldWidget.fallback, widget.fallback)) {
logic.updateSources(sections: widget.sections, fallback: widget.fallback);
}
}
@override
void dispose() {
logic.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => ListenableBuilder(
listenable: logic,
builder: (context, _) => _page(context),
);
Widget _page(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final content = SafeArea(
bottom: !widget.embedded,
child: Container(
key: const Key('ranking-page'),
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFF17131A), AppColors.ink],
stops: [0, 0.42],
),
),
child: Column(
children: [
_header(l10n),
_boardSelector(l10n),
const SizedBox(height: AppDimens.spaceMd),
Expanded(child: _entries(l10n)),
],
),
),
);
return Theme(
data: AppTheme.dark(),
child: widget.embedded
? content
: Scaffold(backgroundColor: AppColors.ink, body: content),
);
}
Widget _header(AppLocalizations l10n) => SizedBox(
height: _pageHeaderHeight,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: AppDimens.pageHorizontal),
child: Row(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: AppColors.blue.withValues(alpha: 0.16),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.blue.withValues(alpha: 0.3)),
),
child: const Icon(
Icons.leaderboard_rounded,
color: AppColors.blue,
size: 20,
),
),
const SizedBox(width: AppDimens.spaceMd),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.rankingTitle,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: AppDimens.pageTitle,
fontWeight: FontWeight.w800,
letterSpacing: -0.4,
),
),
Text(
l10n.rankingSubtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.subtle,
fontSize: AppDimens.caption,
),
),
],
),
),
],
),
),
);
Widget _boardSelector(AppLocalizations l10n) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: AppDimens.pageHorizontal),
child: Container(
height: _selectorHeight,
padding: const EdgeInsets.all(AppDimens.spaceXs),
decoration: BoxDecoration(
color: AppColors.card,
borderRadius: BorderRadius.circular(AppDimens.pillRadius),
border: Border.all(color: AppColors.border),
),
child: Row(
children: [
_boardButton(
key: const Key('ranking-tab-hot'),
label: l10n.rankingHot,
board: RankingBoard.hot,
),
_boardButton(
key: const Key('ranking-tab-new'),
label: l10n.rankingNew,
board: RankingBoard.newReleases,
),
],
),
),
);
}
Widget _boardButton({
required Key key,
required String label,
required RankingBoard board,
}) {
final selected = logic.board == board;
return Expanded(
child: Material(
color: Colors.transparent,
child: InkWell(
key: key,
borderRadius: BorderRadius.circular(AppDimens.pillRadius),
onTap: () => logic.select(board),
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
alignment: Alignment.center,
decoration: BoxDecoration(
color: selected ? AppColors.blue : Colors.transparent,
borderRadius: BorderRadius.circular(AppDimens.pillRadius),
),
child: Text(
label,
style: TextStyle(
color: selected ? AppColors.onPrimary : AppColors.muted,
fontSize: AppDimens.body,
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
),
),
),
),
),
);
}
Widget _entries(AppLocalizations l10n) {
final entries = logic.entries;
if (entries.isEmpty) {
return EmptyState(
key: const Key('ranking-empty'),
icon: Icons.leaderboard_outlined,
title: l10n.rankingEmptyTitle,
subtitle: l10n.rankingEmptyBody,
);
}
return ListView.separated(
key: const Key('ranking-list'),
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
physics: const AlwaysScrollableScrollPhysics(),
itemCount: entries.length,
separatorBuilder: (_, _) => const SizedBox(height: AppDimens.spaceSm),
itemBuilder: (context, index) {
final entry = entries[index];
if (index == 0) return _champion(entry, l10n);
return _rankRow(entry, l10n);
},
);
}
Widget _champion(RankingEntry entry, AppLocalizations l10n) {
final drama = entry.drama;
return SizedBox(
key: Key('ranking-entry-${drama.id}'),
height: _championHeight,
child: Material(
color: AppColors.card,
borderRadius: BorderRadius.circular(AppDimens.authCardRadius),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () => widget.onOpen(drama),
child: Stack(
fit: StackFit.expand,
children: [
RemoteImage(drama.posterUrl),
const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0x22000000), Color(0xF20B0C10)],
stops: [0.15, 1],
),
),
),
Positioned(
top: AppDimens.spaceMd,
left: AppDimens.spaceMd,
child: _rankBadge(entry.rank, large: true),
),
Positioned(
top: AppDimens.spaceMd,
right: AppDimens.spaceMd,
child: _topPickPill(l10n.rankingTopPick),
),
Positioned(
left: AppDimens.spaceLg,
right: AppDimens.spaceLg,
bottom: AppDimens.spaceLg,
child: _championCopy(drama, l10n),
),
],
),
),
),
);
}
Widget _championCopy(Drama drama, AppLocalizations l10n) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
drama.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 22,
fontWeight: FontWeight.w800,
height: 1.15,
letterSpacing: -0.4,
),
),
const SizedBox(height: AppDimens.spaceSm),
Row(
children: [
Expanded(
child: Text(
_subtitle(drama, l10n),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: AppColors.muted, fontSize: 12),
),
),
const Icon(Icons.play_circle_fill_rounded, color: AppColors.blue),
],
),
],
);
Widget _rankRow(RankingEntry entry, AppLocalizations l10n) {
final drama = entry.drama;
return Material(
key: Key('ranking-entry-${drama.id}'),
color: AppColors.card.withValues(alpha: 0.82),
borderRadius: BorderRadius.circular(AppDimens.glassRadius),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () => widget.onOpen(drama),
child: SizedBox(
height: 104,
child: Padding(
padding: const EdgeInsets.all(AppDimens.spaceMd),
child: Row(
children: [
SizedBox(
width: _rankWidth,
child: Center(child: _rankBadge(entry.rank)),
),
const SizedBox(width: AppDimens.spaceSm),
ClipRRect(
borderRadius: BorderRadius.circular(AppDimens.coverRadius),
child: SizedBox(
width: _posterWidth,
height: _posterHeight,
child: RemoteImage(drama.posterUrl),
),
),
const SizedBox(width: AppDimens.spaceMd),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
drama.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: AppDimens.listTitle,
fontWeight: FontWeight.w700,
height: 1.25,
),
),
const SizedBox(height: AppDimens.spaceXs),
Text(
_subtitle(drama, l10n),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.subtle,
fontSize: AppDimens.caption,
),
),
],
),
),
const Icon(
Icons.chevron_right_rounded,
color: AppColors.subtle,
size: 20,
),
],
),
),
),
),
);
}
Widget _rankBadge(int rank, {bool large = false}) {
final top = rank <= _topThreeLimit;
return Container(
width: large ? 44 : 30,
height: large ? 34 : 30,
alignment: Alignment.center,
decoration: BoxDecoration(
color: top
? AppColors.gold.withValues(alpha: large ? 0.95 : 0.12)
: AppColors.raised,
borderRadius: BorderRadius.circular(large ? 11 : 9),
border: top && !large
? Border.all(color: AppColors.gold.withValues(alpha: 0.35))
: null,
),
child: Text(
'$rank',
style: TextStyle(
color: top && large ? AppColors.onGold : AppColors.gold,
fontSize: large ? 17 : 13,
fontWeight: FontWeight.w900,
),
),
);
}
Widget _topPickPill(String label) => Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: AppColors.blue.withValues(alpha: 0.9),
borderRadius: BorderRadius.circular(AppDimens.pillRadius),
),
child: Text(
label,
style: const TextStyle(
color: AppColors.onPrimary,
fontSize: 9,
fontWeight: FontWeight.w800,
letterSpacing: 0.8,
),
),
);
String _subtitle(Drama drama, AppLocalizations l10n) {
final genre = drama.genre.isNotEmpty ? drama.genre : drama.category;
final count = l10n.episodeCount(drama.episodeCount);
return genre.isEmpty ? count : '$genre · $count';
}
}
+91
View File
@@ -0,0 +1,91 @@
// Author: fruit.com.cn
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../core/data/services/auth_controller.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_theme.dart';
import '../../core/widgets/auth_gate.dart';
import '../../l10n/app_localizations.dart';
import '../account/account_entry_card.dart';
import '../notices/notices_page.dart';
/// 设置页:收纳账号、通知与登录态,不提供手动语言切换。
class SettingsPage extends StatelessWidget {
const SettingsPage({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final auth = context.watch<AuthController>();
return Theme(
data: AppTheme.dark(),
child: Scaffold(
key: const Key('settings-page'),
backgroundColor: AppColors.ink,
appBar: AppBar(
backgroundColor: AppColors.ink,
foregroundColor: AppColors.onSurface,
title: Text(l10n.settings),
),
body: SafeArea(
top: false,
child: ListView(
padding: const EdgeInsets.fromLTRB(14, 12, 14, 28),
children: [
if (auth.isSignedIn) ...[
const AccountEntryCard(),
const SizedBox(height: 12),
],
_noticesTile(context, l10n),
const SizedBox(height: 24),
_authAction(context, l10n, auth),
],
),
),
),
);
}
Widget _noticesTile(BuildContext context, AppLocalizations l10n) {
return Material(
color: AppColors.card,
borderRadius: BorderRadius.circular(14),
clipBehavior: Clip.antiAlias,
child: ListTile(
key: const Key('settings-notices'),
leading: const Icon(
Icons.notifications_none_rounded,
color: AppColors.blueSoft,
),
title: Text(l10n.notices),
trailing: const Icon(Icons.chevron_right_rounded),
onTap: () => Navigator.of(
context,
).push<void>(MaterialPageRoute(builder: (_) => const NoticesPage())),
),
);
}
Widget _authAction(
BuildContext context,
AppLocalizations l10n,
AuthController auth,
) {
if (auth.isSignedIn) {
return OutlinedButton(
key: const Key('settings-sign-out'),
onPressed: () => unawaited(auth.signOut()),
child: Text(l10n.signOut),
);
}
return FilledButton(
key: const Key('settings-sign-in'),
onPressed: () => unawaited(openAuthPage(context)),
child: Text(l10n.signIn),
);
}
}
+4 -1
View File
@@ -26,8 +26,11 @@ void main() {
await tester.tap(find.byKey(const Key('tab-my')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('mine-settings')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('mine-account-card')), findsNothing);
expect(find.byKey(const Key('settings-sign-in')), findsOneWidget);
});
testWidgets('should_show_four_account_entries_when_signed_in', (
@@ -46,7 +49,7 @@ void main() {
await tester.tap(find.byKey(const Key('tab-my')));
await tester.pumpAndSettle();
await tester.drag(find.byType(ListView), const Offset(0, -520));
await tester.tap(find.byKey(const Key('mine-settings')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('mine-account-card')), findsOneWidget);
+27 -44
View File
@@ -49,44 +49,34 @@ void main() {
},
);
test(
'should_map_category_id_to_discover_section_when_category_is_selected',
() async {
final repository = ScriptedCategoryRepository(
categories: const [
ContentCategory(id: '7', name: 'Romance'),
ContentCategory(id: '8', name: 'Action'),
test('should_return_category_dramas_when_category_id_exists', () async {
final repository = ScriptedCategoryRepository(
categories: const [
ContentCategory(id: '7', name: 'Romance'),
ContentCategory(id: '8', name: 'Action'),
],
feed: HomeFeed(
sections: [
FeedSection(code: 'hot', title: 'Hot', dramas: [_drama('hot-1')]),
FeedSection(code: 'new', title: 'New', dramas: [_drama('new-1')]),
FeedSection(
code: 'category-7',
title: 'Romance',
dramas: [_drama('romance-1')],
),
],
feed: HomeFeed(
sections: [
FeedSection(code: 'hot', title: 'Hot', dramas: [_drama('hot-1')]),
FeedSection(code: 'new', title: 'New', dramas: [_drama('new-1')]),
FeedSection(
code: 'category-7',
title: 'Romance',
dramas: [_drama('romance-1')],
),
],
),
);
final controller = CategoryController(repository);
),
);
final controller = CategoryController(repository);
await controller.load();
controller.selectCategory('7');
await controller.load();
expect(controller.selectedCode, 'category-7');
expect(controller.visibleDramas.map((drama) => drama.id), ['romance-1']);
expect(controller.selectedSectionMissing, isFalse);
controller.selectCategory('8');
expect(controller.selectedCode, 'category-8');
expect(controller.visibleDramas, isEmpty);
expect(controller.selectedSectionMissing, isTrue);
},
);
expect(controller.dramasOf('7').map((drama) => drama.id), ['romance-1']);
expect(controller.dramasOf('8'), isEmpty);
});
test(
'should_keep_hot_and_new_entries_when_discover_feed_provides_them',
'should_keep_hot_and_new_sections_when_discover_feed_provides_them',
() async {
final repository = ScriptedCategoryRepository(
feed: HomeFeed(
@@ -104,15 +94,12 @@ void main() {
final controller = CategoryController(repository);
await controller.load();
controller.selectCode(CategoryController.hotCode);
expect(controller.visibleDramas.map((drama) => drama.id), ['hot-1']);
controller.selectCode(CategoryController.newCode);
expect(controller.visibleDramas.map((drama) => drama.id), ['new-1']);
expect(controller.feed.sections.first.code, 'hot');
expect(controller.feed.sections[1].dramas.single.id, 'new-1');
},
);
test('should_dedupe_dramas_when_all_categories_are_selected', () async {
test('should_dedupe_dramas_when_feed_sections_overlap', () async {
final repository = ScriptedCategoryRepository(
feed: HomeFeed(
sections: [
@@ -129,11 +116,7 @@ void main() {
await controller.load();
expect(controller.selectedCode, CategoryController.allCode);
expect(controller.visibleDramas.map((drama) => drama.id), [
'same',
'other',
]);
expect(controller.feed.dramas.map((drama) => drama.id), ['same', 'other']);
});
test('should_clear_error_and_retry_when_initial_load_fails', () async {
+19
View File
@@ -0,0 +1,19 @@
// Author: fruit.com.cn
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/config/app_locale.dart';
void main() {
test('should_resolve_supported_device_languages', () {
expect(resolveAppLocale(const Locale('en')), const Locale('en'));
expect(resolveAppLocale(const Locale('zh')), const Locale('zh'));
expect(resolveAppLocale(const Locale('id')), const Locale('id'));
expect(resolveAppLocale(const Locale('in')), const Locale('id'));
});
test('should_fallback_to_indonesian_when_device_language_is_unsupported', () {
expect(resolveAppLocale(const Locale('fr')), const Locale('id'));
expect(resolveAppLocale(null), const Locale('id'));
});
}
-123
View File
@@ -1,123 +0,0 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/config/locale_controller.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
import 'package:shared_preferences_platform_interface/types.dart';
void main() {
setUp(() {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.empty();
});
test('should_keep_device_locale_when_no_choice_is_stored', () async {
final controller = LocaleController();
var notifications = 0;
controller.addListener(() => notifications++);
await controller.load();
expect(controller.locale, isNull);
expect(notifications, 1);
});
test('should_restore_chosen_language_when_app_restarts', () async {
final first = LocaleController();
await first.select('zh');
expect(first.locale, const Locale('zh'));
final reopened = LocaleController();
await reopened.load();
expect(reopened.locale, const Locale('zh'));
});
test('should_restore_indonesian_when_selected_and_app_restarts', () async {
final first = LocaleController();
await first.select('id');
expect(first.locale, const Locale('id'));
final reopened = LocaleController();
await reopened.load();
expect(reopened.locale, const Locale('id'));
});
test('should_fallback_to_indonesian_when_system_language_is_unsupported', () {
expect(resolveAppLocale(null, const Locale('en')), const Locale('en'));
expect(resolveAppLocale(null, const Locale('id')), const Locale('id'));
expect(resolveAppLocale(null, const Locale('fr')), const Locale('id'));
expect(resolveAppLocale(null, const Locale('in')), const Locale('id'));
expect(
resolveAppLocale(const Locale('zh'), const Locale('fr')),
const Locale('zh'),
);
});
test(
'should_clear_stored_chinese_preference_when_english_is_selected',
() async {
await LocaleController().select('zh');
final reopened = LocaleController();
await reopened.load();
await reopened.select('en');
final third = LocaleController();
await third.load();
expect(third.locale, const Locale('en'));
},
);
test('should_clear_locale_when_code_is_unsupported', () async {
final controller = LocaleController();
await controller.select('zh');
expect(controller.locale, const Locale('zh'));
await controller.select('tlh');
expect(controller.locale, isNull);
});
test(
'should_resolve_to_device_locale_when_stored_code_is_unsupported',
() async {
await SharedPreferencesAsync().setString('ui.locale', 'fr');
final controller = LocaleController();
await controller.load();
expect(controller.locale, isNull);
},
);
test(
'should_survive_load_and_select_when_preference_store_is_broken',
() async {
SharedPreferencesAsyncPlatform.instance = _BrokenPreferences();
final controller = LocaleController();
await controller.load();
expect(controller.locale, isNull);
await controller.select('zh');
expect(controller.locale, const Locale('zh'));
},
);
}
/// Stands in for a platform whose preference store is unavailable.
base class _BrokenPreferences extends InMemorySharedPreferencesAsync {
_BrokenPreferences() : super.empty();
@override
Future<String?> getString(String key, SharedPreferencesOptions options) =>
Future<String?>.error(StateError('preferences unavailable'));
@override
Future<bool> setString(
String key,
String value,
SharedPreferencesOptions options,
) => Future<bool>.error(StateError('preferences unavailable'));
}
+31 -323
View File
@@ -1,255 +1,32 @@
// Author: fruit.com.cn
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/models/drama.dart';
import 'package:fruitshorts/l10n/app_localizations.dart';
import 'package:fruitshorts/pages/discover/discover_logic.dart';
import '../support/fake_watch_history.dart';
Drama _drama(
String id, {
List<int> numbers = const [1, 2, 3],
String language = '',
}) => Drama(
language: language,
id: id,
title: id,
synopsis: 'synopsis',
posterUrl: '/files/poster.png',
episodes: [
for (final number in numbers)
Episode(
id: '$id-$number',
number: number,
title: 'Episode $number',
videoUrl: 'https://example.test/clip.mp4',
isFree: true,
),
],
);
import 'package:fruitshorts/pages/discover/discover_types.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test(
'should_resolve_resume_position_of_episode_one_when_constructed',
() async {
final history = FakeWatchHistory()..saved = const Duration(seconds: 4);
final logic = DiscoverLogic(history: history, drama: _drama('signal'));
test('should_report_query_only_for_non_whitespace_text', () {
final logic = DiscoverLogic();
expect(await logic.position, const Duration(seconds: 4));
expect(history.reads, ['signal']);
expect(logic.searchOpen, isFalse);
logic.dispose();
},
);
expect(logic.hasSearchQuery, isFalse);
test('should_reread_resume_position_when_drama_is_swapped', () async {
final history = FakeWatchHistory();
final logic = DiscoverLogic(history: history, drama: _drama('signal'));
logic.search.text = ' ';
expect(logic.hasSearchQuery, isFalse);
logic.drama = _drama('city');
logic.refreshPosition();
await logic.position;
expect(history.reads, ['signal', 'city']);
logic.search.text = 'remote';
expect(logic.hasSearchQuery, isTrue);
logic.dispose();
});
test(
'should_resume_from_last_watch_when_list_card_has_no_episodes',
() async {
final history = FakeWatchHistory()
..saved = const Duration(seconds: 9)
..lastEpisode = 'signal-2';
final logic = DiscoverLogic(
history: history,
drama: _drama('signal', numbers: const []),
);
expect(await logic.position, const Duration(seconds: 9));
expect(history.reads, ['signal']);
logic.dispose();
},
);
test('should_filter_visible_dramas_when_language_is_selected', () {
final logic = DiscoverLogic(
history: FakeWatchHistory(),
drama: _drama('s'),
);
final dramas = [
_drama('zh', language: 'zh-CN'),
_drama('id', language: 'id'),
_drama('unknown'),
];
expect(logic.visibleDramas(dramas), hasLength(3));
logic.selectLanguage('id');
expect(logic.visibleDramas(dramas).map((item) => item.id), ['id']);
logic.selectLanguage(null);
expect(logic.visibleDramas(dramas), hasLength(3));
logic.dispose();
});
test('should_list_distinct_languages_when_feed_has_cards', () {
final logic = DiscoverLogic(
history: FakeWatchHistory(),
drama: _drama('s'),
);
final dramas = [
_drama('zh', language: 'zh-CN'),
_drama('id', language: 'id'),
_drama('id2', language: 'id'),
_drama('unknown'),
];
expect(logic.languagesOf(dramas), ['zh-CN', 'id']);
logic.dispose();
});
test('should_have_no_resume_position_when_server_has_no_record', () async {
final history = FakeWatchHistory();
final logic = DiscoverLogic(
history: history,
drama: _drama('signal', numbers: const []),
);
expect(await logic.position, isNull);
// 续看位置改为按剧问服务端:即使卡片没有剧集也会读一次
expect(history.reads, ['signal']);
logic.dispose();
});
test('should_show_search_when_flag_is_on_or_text_is_typed', () {
final logic = DiscoverLogic(
history: FakeWatchHistory(),
drama: _drama('s'),
);
expect(logic.showSearch, isFalse);
logic.search.text = 'vengeance';
logic.onSearchChanged();
expect(logic.showSearch, isTrue);
logic.search.clear();
expect(logic.showSearch, isFalse);
logic.searchOpen = true;
expect(logic.showSearch, isTrue);
logic.dispose();
});
test('should_match_by_id_and_tolerate_missing_id_when_looking_up_drama', () {
final logic = DiscoverLogic(
history: FakeWatchHistory(),
drama: _drama('s'),
);
final catalog = [_drama('signal'), _drama('city')];
expect(logic.dramaFor('city', catalog)?.id, 'city');
expect(logic.dramaFor('missing', catalog), isNull);
expect(logic.dramaFor(null, catalog), isNull);
expect(logic.dramaFor('city', const []), isNull);
logic.dispose();
});
test('should_adopt_drama_only_when_page_is_active', () {
final history = FakeWatchHistory();
final logic = DiscoverLogic(history: history, drama: _drama('signal'));
void sync(
Drama next, {
required bool active,
required bool wasActive,
required bool dramaChanged,
int searchRequest = 0,
int previousSearchRequest = 0,
}) => logic.syncDrama(
active: active,
wasActive: wasActive,
nextDrama: next,
dramaChanged: dramaChanged,
searchRequest: searchRequest,
previousSearchRequest: previousSearchRequest,
isActive: () => active,
);
// An inactive page keeps showing its own drama.
sync(_drama('city'), active: false, wasActive: true, dramaChanged: true);
expect(logic.drama.id, 'signal');
// Becoming active adopts the incoming drama.
sync(_drama('city'), active: true, wasActive: false, dramaChanged: false);
expect(logic.drama.id, 'city');
// Staying active with a changed drama re-reads history.
history.reads.clear();
sync(
_drama('vengeance'),
active: true,
wasActive: true,
dramaChanged: true,
);
expect(logic.drama.id, 'vengeance');
expect(history.reads, ['vengeance']);
// Staying active with no change leaves both drama and history alone.
history.reads.clear();
sync(_drama('other'), active: true, wasActive: true, dramaChanged: false);
expect(logic.drama.id, 'vengeance');
expect(history.reads, isEmpty);
logic.dispose();
});
testWidgets(
'should_open_and_focus_field_when_search_is_requested_and_page_is_active',
(tester) async {
final logic = DiscoverLogic(
history: FakeWatchHistory(),
drama: _drama('s'),
);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: TextField(
controller: logic.search,
focusNode: logic.searchFocus,
),
),
),
);
logic.syncDrama(
active: true,
wasActive: true,
nextDrama: logic.drama,
dramaChanged: false,
searchRequest: 1,
previousSearchRequest: 0,
isActive: () => true,
);
expect(logic.searchOpen, isTrue);
await tester.pumpAndSettle();
expect(logic.searchFocus.hasFocus, isTrue);
await tester.pumpWidget(const SizedBox.shrink());
logic.dispose();
},
);
testWidgets('should_keep_search_field_closed_when_page_is_hidden', (
testWidgets('should_focus_search_only_for_new_request_on_active_page', (
tester,
) async {
final logic = DiscoverLogic(
history: FakeWatchHistory(),
drama: _drama('s'),
);
final logic = DiscoverLogic();
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
@@ -261,33 +38,33 @@ void main() {
),
);
// The search request branch is gated on `active`, so a page that is not on
// screen must neither reveal the field nor pull focus away from the visible tab.
logic.syncDrama(
logic.syncSearch(
active: false,
wasActive: false,
nextDrama: logic.drama,
dramaChanged: false,
searchRequest: 1,
previousSearchRequest: 0,
request: 1,
previousRequest: 0,
isActive: () => false,
);
expect(logic.searchOpen, isFalse);
await tester.pumpAndSettle();
await tester.pump();
expect(logic.searchFocus.hasFocus, isFalse);
logic.syncSearch(
active: true,
request: 2,
previousRequest: 1,
isActive: () => true,
);
await tester.pump();
await tester.pump();
expect(logic.searchFocus.hasFocus, isTrue);
await tester.pumpWidget(const SizedBox.shrink());
logic.dispose();
});
testWidgets('should_return_choice_and_forward_it_when_act_runs_embedded', (
testWidgets('should_forward_action_when_embedded_or_pop_when_standalone', (
tester,
) async {
final logic = DiscoverLogic(
history: FakeWatchHistory(),
drama: _drama('s'),
);
final logic = DiscoverLogic();
final forwarded = <DiscoverPageAction>[];
final popped = Completer<DiscoverPageAction?>();
late BuildContext pageContext;
@@ -311,7 +88,6 @@ void main() {
),
),
);
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
@@ -322,83 +98,15 @@ void main() {
onAction: forwarded.add,
);
expect(forwarded, [DiscoverPageAction.home]);
expect(Navigator.of(pageContext).canPop(), isTrue);
logic.act(pageContext, DiscoverPageAction.watchlist, embedded: false);
logic.act(pageContext, DiscoverPageAction.my, embedded: false);
await tester.pumpAndSettle();
await expectLater(popped.future, completion(DiscoverPageAction.watchlist));
logic.dispose();
});
testWidgets('should_show_artwork_notice_when_card_has_no_playable_media', (
tester,
) async {
final logic = DiscoverLogic(
history: FakeWatchHistory(),
drama: _drama('s'),
);
late BuildContext context;
await tester.pumpWidget(
MaterialApp(
locale: const Locale('en'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Builder(
builder: (inner) {
context = inner;
return const Scaffold(body: SizedBox.shrink());
},
),
),
);
logic.openCard(context, null, embedded: false);
await tester.pumpAndSettle();
final l10n = AppLocalizations.of(context)!;
expect(find.text(l10n.artworkOnlyNotice), findsOneWidget);
logic.dispose();
});
testWidgets('should_forward_chosen_drama_when_card_is_embedded', (
tester,
) async {
final logic = DiscoverLogic(
history: FakeWatchHistory(),
drama: _drama('s'),
);
late BuildContext context;
Drama? selected;
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (inner) {
context = inner;
return const SizedBox.shrink();
},
),
),
);
logic.openCard(
context,
_drama('city'),
embedded: true,
onDramaSelected: (drama) => selected = drama,
);
expect(selected?.id, 'city');
await expectLater(popped.future, completion(DiscoverPageAction.my));
logic.dispose();
});
test('should_do_nothing_when_notifying_after_dispose', () {
final logic = DiscoverLogic(
history: FakeWatchHistory(),
drama: _drama('s'),
);
final logic = DiscoverLogic();
var notifications = 0;
logic.addListener(() => notifications++);
+10 -22
View File
@@ -7,8 +7,6 @@ import 'package:fruitshorts/core/data/remote/api_exception.dart';
import 'package:fruitshorts/core/data/repositories/search_repository.dart';
import 'package:fruitshorts/pages/discover/discover_logic.dart';
import '../support/fake_watch_history.dart';
Drama _drama(String id, {String language = 'id'}) => Drama(
id: id,
title: id,
@@ -25,11 +23,8 @@ void main() {
'should_query_backend_and_render_results_when_search_text_changes',
() async {
final repository = _FakeSearchRepository(results: [_drama('velvet')]);
final logic = DiscoverLogic(
history: FakeWatchHistory(),
drama: _drama('signal'),
searchRepository: repository,
)..searchDebounce = Duration.zero;
final logic = DiscoverLogic(searchRepository: repository)
..searchDebounce = Duration.zero;
logic.search.text = 'velvet';
logic.onSearchChanged();
@@ -39,18 +34,15 @@ void main() {
(keyword: 'velvet', pageIndex: 1, pageSize: 20),
]);
expect(logic.searchLoading, isFalse);
expect(logic.visibleDramas(const []).single.id, 'velvet');
expect(logic.searchResults.single.id, 'velvet');
logic.dispose();
},
);
test('should_keep_server_results_when_text_only_loses_whitespace', () async {
final repository = _FakeSearchRepository(results: [_drama('velvet')]);
final logic = DiscoverLogic(
history: FakeWatchHistory(),
drama: _drama('signal'),
searchRepository: repository,
)..searchDebounce = Duration.zero;
final logic = DiscoverLogic(searchRepository: repository)
..searchDebounce = Duration.zero;
logic.search.text = 'velvet ';
logic.onSearchChanged();
@@ -60,14 +52,12 @@ void main() {
await pumpEventQueue();
expect(repository.calls, hasLength(1));
expect(logic.visibleDramas(const []).single.id, 'velvet');
expect(logic.searchResults.single.id, 'velvet');
logic.dispose();
});
test('should_expose_error_message_when_search_fails', () async {
final logic = DiscoverLogic(
history: FakeWatchHistory(),
drama: _drama('signal'),
searchRepository: _FakeSearchRepository(
error: const ApiException(400, 'Keyword is invalid'),
),
@@ -84,11 +74,8 @@ void main() {
test('should_clear_server_results_when_search_text_is_empty', () async {
final repository = _FakeSearchRepository(results: [_drama('velvet')]);
final logic = DiscoverLogic(
history: FakeWatchHistory(),
drama: _drama('signal'),
searchRepository: repository,
)..searchDebounce = Duration.zero;
final logic = DiscoverLogic(searchRepository: repository)
..searchDebounce = Duration.zero;
logic.search.text = 'velvet';
logic.onSearchChanged();
@@ -96,7 +83,8 @@ void main() {
logic.search.clear();
logic.onSearchChanged();
expect(logic.visibleDramas([_drama('local')]).single.id, 'local');
expect(logic.hasSearchQuery, isFalse);
expect(logic.searchResults, isEmpty);
logic.dispose();
});
}
+62 -327
View File
@@ -1,17 +1,17 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
import 'package:fruitshorts/app.dart';
import 'package:fruitshorts/core/data/models/content_category.dart';
import 'package:fruitshorts/core/data/models/drama.dart';
import 'package:fruitshorts/core/data/models/home_feed.dart';
import 'package:fruitshorts/core/data/models/watch_progress.dart';
import 'package:fruitshorts/core/data/repositories/category_repository.dart';
import 'package:fruitshorts/core/data/repositories/favorite_repository.dart';
import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart';
import 'package: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_interaction_repositories.dart';
@@ -24,173 +24,65 @@ void main() {
InMemorySharedPreferencesAsync.empty();
});
testWidgets(
'should_show_all_dramas_in_two_column_masonry_without_continue_watching',
(tester) async {
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
testWidgets('should_show_search_categories_ranking_and_all_dramas', (
tester,
) async {
await _pumpTheater(tester, categoryRepository: _GridCategoryRepository());
await _pumpApp(tester, categoryRepository: _GridCategoryRepository());
await tester.tap(find.byKey(const Key('tab-discover')));
await tester.pumpAndSettle();
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('home-all-dramas-grid')), findsOneWidget);
expect(find.byKey(const Key('ranking-tab-hot')), findsOneWidget);
expect(find.byKey(const Key('ranking-tab-new')), findsOneWidget);
expect(find.text('Continue Watching'), findsNothing);
expect(find.text('Recommended'), findsOneWidget);
for (final key in const [
Key('discover-search-button'),
Key('discover-profile'),
]) {
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.text('For You'), findsNothing);
final masonry = tester.widget<Row>(
find.byKey(const Key('home-all-dramas-grid')),
);
expect(masonry.children, hasLength(3));
expect((masonry.children[1] as SizedBox).width, 10);
expect(
tester
.widget<Padding>(find.byKey(const Key('home-all-dramas-section')))
.padding,
const EdgeInsets.symmetric(horizontal: 14),
);
expect(find.byType(GridView), findsNothing);
expect(find.byType(CustomScrollView), findsOneWidget);
final signalCard = find.byKey(
const Key('home-drama-card-the-signal-demo'),
);
final cityCard = find.byKey(
const Key('home-drama-card-city-lights-demo'),
);
final vengeanceCard = find.byKey(
const Key('home-drama-card-vengeance-demo'),
);
expect(tester.getTopLeft(signalCard).dy, tester.getTopLeft(cityCard).dy);
expect(
tester.getTopLeft(vengeanceCard).dx,
tester.getTopLeft(signalCard).dx,
);
expect(
tester.getTopLeft(vengeanceCard).dy -
tester.getBottomLeft(signalCard).dy,
closeTo(10, 1),
);
final cover = find.byKey(const Key('home-drama-cover-city-lights-demo'));
expect(tester.getSize(cover).height, greaterThan(120));
expect(
tester
.widget<Image>(
find.descendant(of: cover, matching: find.byType(Image)),
)
.fit,
BoxFit.fitWidth,
);
expect(
find.descendant(
of: cover,
matching: find.text('City Lights & Velvet Nights'),
),
findsNothing,
);
expect(
find.byKey(const Key('home-drama-title-city-lights-demo')),
findsOneWidget,
);
expect(
tester
.getTopLeft(
find.byKey(const Key('home-drama-title-city-lights-demo')),
)
.dy,
greaterThan(tester.getBottomLeft(cover).dy),
);
expect(
tester
.widget<Text>(
find.byKey(const Key('home-drama-title-city-lights-demo')),
)
.maxLines,
1,
);
expect(
tester
.widget<Text>(
find.byKey(const Key('home-drama-genre-city-lights-demo')),
)
.maxLines,
1,
);
expect(tester.takeException(), isNull);
},
);
testWidgets('should_update_grid_when_category_is_selected', (tester) async {
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await _pumpApp(tester, categoryRepository: _GridCategoryRepository());
await tester.tap(find.byKey(const Key('tab-discover')));
await tester.pumpAndSettle();
expect(find.text('The Signal'), findsWidgets);
expect(find.text('City Lights & Velvet Nights'), findsWidgets);
await tester.tap(find.text('Romance'));
await tester.pumpAndSettle();
expect(find.text('The Signal'), findsNothing);
expect(find.text('City Lights & Velvet Nights'), findsWidgets);
expect(
find.byKey(const Key('home-drama-card-the-signal-demo')),
findsNothing,
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', (
tester,
) async {
await _pumpTheater(tester, categoryRepository: _GridCategoryRepository());
await tester.tap(find.byKey(const Key('discover-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,
);
expect(
find.byKey(const Key('home-drama-card-the-signal-demo')),
findsNothing,
);
});
testWidgets('should_keep_discover_header_fixed_when_content_scrolls', (
tester,
) async {
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await _pumpApp(
await _pumpTheater(
tester,
categoryRepository: _GridCategoryRepository(
discoverFeed: _tallDiscoverFeed,
),
);
await tester.tap(find.byKey(const Key('tab-discover')));
await tester.pumpAndSettle();
final headerBefore = tester.getTopLeft(
find.byKey(const Key('discover-search-button')),
);
final contentBefore = tester.getTopLeft(
find.byKey(const Key('home-category-chips')),
find.byKey(const Key('discover-category-grid')),
);
await tester.drag(find.byType(CustomScrollView), const Offset(0, -40));
await tester.drag(find.byType(ListView), const Offset(0, -140));
await tester.pumpAndSettle();
expect(
@@ -198,158 +90,37 @@ void main() {
headerBefore,
);
expect(
tester.getTopLeft(find.byKey(const Key('home-category-chips'))).dy,
tester.getTopLeft(find.byKey(const Key('discover-category-grid'))).dy,
lessThan(contentBefore.dy),
);
});
testWidgets('should_keep_empty_messages_when_feed_has_no_dramas', (
tester,
) async {
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await _pumpApp(
testWidgets('should_show_empty_and_retry_states', (tester) async {
await _pumpTheater(
tester,
categoryRepository: _GridCategoryRepository(
discoverFeed: const HomeFeed(),
),
);
await tester.tap(find.byKey(const Key('tab-discover')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('discover-empty')), findsOneWidget);
expect(find.text('No matching dramas.'), findsOneWidget);
expect(find.byKey(const Key('empty-state-content')), findsOneWidget);
await tester.pumpWidget(const SizedBox.shrink());
await _pumpTheater(
tester,
categoryRepository: _FailingCategoryRepository(),
);
await tester.tap(find.text('Romance'));
await tester.pumpAndSettle();
expect(find.text('No dramas in this category yet.'), findsOneWidget);
});
testWidgets('should_show_retry_when_category_feed_fails', (tester) async {
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await _pumpApp(tester, categoryRepository: _FailingCategoryRepository());
await tester.tap(find.byKey(const Key('tab-discover')));
await tester.pumpAndSettle();
expect(find.text('Categories could not be loaded.'), findsOneWidget);
expect(find.byKey(const Key('home-categories-error')), findsOneWidget);
expect(find.text('Retry'), findsOneWidget);
expect(find.byKey(const Key('empty-state-icon')), findsOneWidget);
final icon = tester.getCenter(find.byKey(const Key('empty-state-icon')));
final feed = tester.getRect(find.byType(CustomScrollView));
expect(icon.dx, closeTo(feed.center.dx, 1));
expect(find.byKey(const Key('home-all-dramas-grid')), findsNothing);
});
testWidgets('should_fit_two_column_grid_on_compact_screen', (tester) async {
tester.view.physicalSize = const Size(320, 568);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await _pumpApp(tester, categoryRepository: _GridCategoryRepository());
await tester.tap(find.byKey(const Key('tab-discover')));
await tester.pumpAndSettle();
await tester.scrollUntilVisible(
find.byKey(const Key('home-drama-card-city-lights-demo')),
180,
scrollable: find.byType(Scrollable).last,
);
await tester.pumpAndSettle();
expect(find.text('City Lights & Velvet Nights'), findsWidgets);
expect(tester.takeException(), isNull);
});
testWidgets('should_filter_trending_grid_when_search_text_changes', (
tester,
) async {
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await _pumpApp(tester, categoryRepository: _GridCategoryRepository());
await tester.tap(find.byKey(const Key('tab-discover')));
await tester.pumpAndSettle();
expect(
find.byKey(const Key('home-drama-card-the-signal-demo')),
findsOneWidget,
);
expect(
find.byKey(const Key('home-drama-card-city-lights-demo')),
findsOneWidget,
);
testWidgets('should_show_search_results_when_text_changes', (tester) async {
await _pumpTheater(tester, categoryRepository: _GridCategoryRepository());
await tester.enterText(find.byKey(const Key('discover-search')), 'velvet');
await tester.pump(const Duration(milliseconds: 400));
await tester.pumpAndSettle();
expect(
find.byKey(const Key('home-drama-card-the-signal-demo')),
findsNothing,
);
expect(
find.byKey(const Key('home-drama-card-city-lights-demo')),
findsOneWidget,
);
await tester.enterText(find.byKey(const Key('discover-search')), '');
await tester.pumpAndSettle();
expect(
find.byKey(const Key('home-drama-card-the-signal-demo')),
findsOneWidget,
);
});
testWidgets('should_unfocus_search_without_losing_input_or_filter', (
tester,
) async {
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await _pumpApp(tester, categoryRepository: _GridCategoryRepository());
await tester.tap(find.byKey(const Key('tab-discover')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('discover-search')));
await tester.pump();
expect(
tester
.widget<TextField>(find.byKey(const Key('discover-search')))
.focusNode!
.hasFocus,
isTrue,
);
await tester.enterText(find.byKey(const Key('discover-search')), 'velvet');
await tester.pumpAndSettle();
expect(
find.byKey(const Key('home-drama-card-city-lights-demo')),
findsOneWidget,
);
expect(
find.byKey(const Key('home-drama-card-the-signal-demo')),
findsNothing,
);
await tester.tapAt(const Offset(70, 23));
await tester.pump();
final field = tester.widget<TextField>(
find.byKey(const Key('discover-search')),
);
expect(field.focusNode!.hasFocus, isFalse);
expect(field.controller!.text, 'velvet');
expect(
find.byKey(const Key('home-drama-card-city-lights-demo')),
findsOneWidget,
@@ -361,20 +132,13 @@ void main() {
});
testWidgets('should_refresh_feed_when_pulled_down', (tester) async {
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final counter = _CountingCategoryRepository(
_GridCategoryRepository(discoverFeed: _tallDiscoverFeed),
);
await _pumpApp(tester, categoryRepository: counter);
await tester.tap(find.byKey(const Key('tab-discover')));
await tester.pumpAndSettle();
await _pumpTheater(tester, categoryRepository: counter);
final before = counter.categoryLoads + counter.feedLoads;
await tester.drag(find.byType(CustomScrollView), const Offset(0, 320));
await tester.drag(find.byType(ListView), const Offset(0, 320));
await tester.pump();
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
@@ -382,47 +146,16 @@ void main() {
expect(counter.categoryLoads + counter.feedLoads, greaterThan(before));
expect(find.byType(RefreshProgressIndicator), findsNothing);
});
testWidgets('should_show_continue_watching_only_when_a_record_exists', (
tester,
) async {
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await _pumpApp(
tester,
categoryRepository: _GridCategoryRepository(),
history: InMemoryWatchHistoryRepository(
seed: const [
WatchProgress(
dramaId: 'city-lights-demo',
episodeId: 'city-lights-demo-1',
position: Duration(seconds: 42),
duration: Duration(minutes: 2),
),
],
),
);
await tester.tap(find.byKey(const Key('tab-discover')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('discover-continue-watching')), findsOneWidget);
expect(find.byKey(const Key('discover-continue-play')), findsOneWidget);
expect(find.text('City Lights & Velvet Nights'), findsWidgets);
expect(find.byKey(const Key('discover-quick-filter')), findsOneWidget);
expect(find.byKey(const Key('discover-quick-charts')), findsOneWidget);
expect(find.byKey(const Key('discover-quick-new')), findsOneWidget);
expect(find.byKey(const Key('discover-quick-watchlist')), findsOneWidget);
});
}
Future<void> _pumpApp(
Future<void> _pumpTheater(
WidgetTester tester, {
required CategoryRepository categoryRepository,
WatchHistoryRepository? history,
}) async {
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final catalog = FakeCatalogRepository();
await tester.pumpWidget(
LumaDramaApp(
@@ -430,7 +163,7 @@ Future<void> _pumpApp(
catalogRepository: catalog,
searchRepository: catalog,
categoryRepository: categoryRepository,
watchHistoryRepository: history ?? InMemoryWatchHistoryRepository(),
watchHistoryRepository: InMemoryWatchHistoryRepository(),
favoriteRepository: InMemoryFavoriteRepository(),
playbackRepository: FakePlaybackRepository(),
commentRepository: FakeCommentRepository(),
@@ -439,6 +172,8 @@ Future<void> _pumpApp(
),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('tab-discover')));
await tester.pumpAndSettle();
}
class _GridCategoryRepository implements CategoryRepository {
+27 -94
View File
@@ -1,9 +1,10 @@
import 'package:flutter/material.dart';
// Author: fruit.com.cn
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/models/drama.dart';
import 'package:fruitshorts/pages/discover/discover_logic.dart';
import 'package:fruitshorts/pages/discover/discover_types.dart';
import 'package:fruitshorts/pages/home/home_logic.dart';
import 'package:fruitshorts/pages/mine/mine_logic.dart';
import 'package:fruitshorts/pages/mine/mine_types.dart';
Drama _drama(String id) => Drama(
id: id,
@@ -18,20 +19,15 @@ void main() {
final dramas = [_drama('signal'), _drama('city')];
test(
'should_make_first_catalog_entry_the_current_drama_when_catalog_loads',
() {
final logic = HomeLogic(dramas);
test('should_make_first_catalog_entry_current_when_catalog_loads', () {
final logic = HomeLogic(dramas);
expect(logic.currentDrama.id, 'signal');
expect(logic.tab, 0);
expect(logic.mySection, 0);
expect(logic.drawerOpen, isFalse);
expect(logic.profile, isFalse);
expect(logic.videoImmersive, isFalse);
logic.dispose();
},
);
expect(logic.currentDrama.id, 'signal');
expect(logic.tab, 0);
expect(logic.drawerOpen, isFalse);
expect(logic.videoImmersive, isFalse);
logic.dispose();
});
test('should_not_build_shell_when_catalog_is_empty', () {
expect(() => HomeLogic(const []), throwsStateError);
@@ -46,7 +42,6 @@ void main() {
expect(logic.tab, 1);
expect(logic.searchRequest, 0);
expect(logic.sectionRequest, 0);
expect(logic.resumeRequest, 0);
expect(logic.backRequest, 0);
expect(notifications, 1);
@@ -57,62 +52,27 @@ void main() {
final logic = HomeLogic(dramas);
logic.select(1, search: true);
expect(logic.tab, 1);
expect(logic.searchRequest, 1);
expect(logic.sectionRequest, 0);
logic.select(2, mySection: 0);
expect(logic.mySection, 0);
expect(logic.sectionRequest, 1);
expect(logic.searchRequest, 1);
expect(logic.resumeRequest, 0);
logic.select(0, resume: true);
expect(logic.resumeRequest, 1);
expect(logic.sectionRequest, 1);
expect(logic.backRequest, 0);
expect(logic.searchRequest, 1);
logic.dispose();
});
test('should_default_to_second_section_when_watchlist_shortcut_is_used', () {
test('should_open_profile_tab_when_profile_action_is_used', () {
final logic = HomeLogic(dramas);
logic.select(2, watchlist: true);
logic.openProfile();
expect(logic.tab, 2);
expect(logic.mySection, 1);
expect(logic.sectionRequest, 1);
logic.dispose();
});
test(
'should_differ_on_profile_flag_when_open_profile_and_open_library_are_used',
() {
final logic = HomeLogic(dramas);
logic.openProfile();
expect(logic.profile, isTrue);
expect(logic.tab, 2);
expect(logic.mySection, 0);
expect(logic.sectionRequest, 1);
logic.openLibrary(section: 1);
expect(logic.profile, isFalse);
expect(logic.mySection, 1);
expect(logic.sectionRequest, 2);
logic.openLibrary();
expect(logic.profile, isFalse);
expect(logic.mySection, 1);
expect(logic.sectionRequest, 3);
logic.dispose();
},
);
test('should_return_to_discover_and_handle_immersive_back', () {
final logic = HomeLogic(dramas);
// Home is the launch tab; the first back press lands on Discover.
logic.onSystemBack();
expect(logic.tab, 1);
logic.onSystemBack();
@@ -122,7 +82,6 @@ void main() {
logic.select(0);
logic.onSystemBack();
expect(logic.tab, 1);
expect(logic.backRequest, 0);
logic.select(0);
logic.onImmersiveChanged(true);
@@ -181,6 +140,17 @@ void main() {
logic.dispose();
});
test('should_map_onto_root_tabs_when_discover_actions_fire', () {
final logic = HomeLogic(dramas);
logic.discoverAction(DiscoverPageAction.my);
expect(logic.tab, 2);
logic.discoverAction(DiscoverPageAction.home);
expect(logic.tab, 0);
logic.dispose();
});
test('should_do_nothing_when_notifying_after_dispose', () {
final logic = HomeLogic(dramas);
var notifications = 0;
@@ -191,41 +161,4 @@ void main() {
expect(notifications, 0);
});
testWidgets('should_map_onto_root_tabs_when_discover_actions_fire', (
tester,
) async {
final logic = HomeLogic(dramas);
late BuildContext context;
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (inner) {
context = inner;
return const SizedBox.shrink();
},
),
),
);
logic.discoverAction(context, DiscoverPageAction.watchlist);
expect(logic.tab, 2);
expect(logic.profile, isFalse);
expect(logic.mySection, 1);
expect(logic.sectionRequest, 1);
logic.discoverAction(context, DiscoverPageAction.my);
expect(logic.tab, 2);
expect(logic.profile, isFalse);
logic.discoverAction(context, DiscoverPageAction.resume);
expect(logic.tab, 0);
expect(logic.resumeRequest, 1);
logic.discoverAction(context, DiscoverPageAction.home);
expect(logic.tab, 0);
expect(tester.takeException(), isNull);
logic.dispose();
});
}
+8 -17
View File
@@ -20,7 +20,6 @@ const _keys = [
Key('home-tabs-bar'),
Key('tab-home'),
Key('tab-discover'),
Key('tab-ranking'),
Key('tab-my'),
];
@@ -34,7 +33,7 @@ void main() {
});
});
testWidgets('should_theme_four_tabs_and_switch_selected_state_when_tapped', (
testWidgets('should_theme_three_tabs_and_switch_selected_state_when_tapped', (
tester,
) async {
final haptics = FakeHaptics();
@@ -96,14 +95,11 @@ void main() {
);
});
testWidgets('should_map_four_tabs_to_home_discover_ranking_and_me', (
tester,
) async {
testWidgets('should_map_three_tabs_to_home_discover_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-ranking')), findsOneWidget);
expect(find.byKey(const Key('tab-my')), findsOneWidget);
expect(find.byKey(const Key('home-feed-info-card')), findsOneWidget);
@@ -111,17 +107,12 @@ void main() {
await tester.pumpAndSettle();
expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget);
await tester.tap(find.byKey(const Key('tab-ranking')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('ranking-page')), findsOneWidget);
await tester.tap(find.byKey(const Key('tab-my')));
await tester.pumpAndSettle();
expect(find.text('Viewer'), findsOneWidget);
expect(
find.byKey(const Key('mine-library-tab-0'), skipOffstage: false),
findsOneWidget,
);
expect(find.byKey(const Key('mine-menu')), findsOneWidget);
expect(find.byKey(const Key('mine-watchlist')), findsOneWidget);
expect(find.byKey(const Key('mine-library-tab-0')), findsNothing);
await tester.tap(find.byKey(const Key('tab-home')));
await tester.pumpAndSettle();
@@ -137,11 +128,11 @@ void main() {
await tester.pumpAndSettle();
haptics.reset();
await tester.drag(find.byType(ListView), const Offset(0, -700));
await tester.pumpAndSettle();
await tester.tap(find.text('App Language'));
await tester.ensureVisible(find.byKey(const Key('mine-settings')));
await tester.tap(find.byKey(const Key('mine-settings')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('settings-page')), findsOneWidget);
expect(haptics.calls, isEmpty);
});
}
+38 -201
View File
@@ -1,173 +1,68 @@
// Author: fruit.com.cn
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/config/locale_controller.dart';
import 'package:fruitshorts/core/data/models/drama.dart';
import 'package:fruitshorts/l10n/app_localizations.dart';
import 'package:fruitshorts/core/data/models/watch_progress.dart';
import 'package:fruitshorts/pages/mine/mine_logic.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
import 'package:fruitshorts/pages/mine/mine_types.dart';
import '../support/fake_watch_history.dart';
Drama _drama(String id, {List<int> numbers = const [1, 2, 3]}) => Drama(
id: id,
title: id,
synopsis: 'synopsis',
posterUrl: '/files/poster.png',
episodes: [
for (final number in numbers)
Episode(
id: '$id-$number',
number: number,
title: 'Episode $number',
videoUrl: 'https://example.test/clip.mp4',
isFree: true,
),
],
const _record = WatchProgress(
dramaId: 'signal',
episodeId: 'signal-1',
position: Duration(seconds: 7),
duration: Duration(minutes: 2),
);
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test(
'should_keep_requested_section_and_read_episode_one_when_constructed',
() async {
final history = FakeWatchHistory()..saved = const Duration(seconds: 7);
final logic = MineLogic(
history: history,
drama: _drama('signal'),
initialSection: 1,
);
test('should_load_recent_watches_when_constructed', () async {
final history = FakeWatchHistory()..recentRecords = const [_record];
final logic = MineLogic(history: history);
expect(logic.section, 1);
expect(logic.filter, 0);
expect(await logic.position, const Duration(seconds: 7));
expect(history.reads, ['signal']);
logic.dispose();
},
);
await pumpEventQueue();
test(
'should_resume_from_last_watch_when_list_card_has_no_episodes',
() async {
final history = FakeWatchHistory()
..saved = const Duration(seconds: 7)
..lastEpisode = 'signal-3';
final logic = MineLogic(
history: history,
drama: _drama('signal', numbers: const []),
initialSection: 1,
);
expect(await logic.position, const Duration(seconds: 7));
expect(history.reads, ['signal']);
logic.dispose();
},
);
test('should_notify_when_section_and_filter_are_selected', () {
final logic = MineLogic(
history: FakeWatchHistory(),
drama: _drama('s'),
initialSection: 0,
);
var notifications = 0;
logic.addListener(() => notifications++);
logic.selectSection(1);
expect(logic.section, 1);
expect(notifications, 1);
logic.selectFilter(2);
expect(logic.filter, 2);
expect(notifications, 2);
expect(logic.recent.single.dramaId, 'signal');
expect(logic.recentLoading, isFalse);
expect(logic.recentError, isNull);
logic.dispose();
});
test('should_adopt_drama_only_when_page_is_active', () {
final history = FakeWatchHistory();
final logic = MineLogic(
history: history,
drama: _drama('signal'),
initialSection: 0,
);
test('should_expose_error_when_recent_request_fails', () async {
final history = FakeWatchHistory()..failReads = true;
final logic = MineLogic(history: history);
void sync(
Drama next, {
required bool active,
required bool wasActive,
required bool dramaChanged,
int sectionRequest = 0,
int previousSectionRequest = 0,
int initialSection = 0,
}) => logic.syncPage(
active: active,
wasActive: wasActive,
nextDrama: next,
dramaChanged: dramaChanged,
sectionRequest: sectionRequest,
previousSectionRequest: previousSectionRequest,
initialSection: initialSection,
);
await pumpEventQueue();
sync(_drama('city'), active: false, wasActive: true, dramaChanged: true);
expect(logic.drama.id, 'signal');
sync(_drama('city'), active: true, wasActive: false, dramaChanged: false);
expect(logic.drama.id, 'city');
history.reads.clear();
sync(
_drama('vengeance'),
active: true,
wasActive: true,
dramaChanged: true,
);
expect(logic.drama.id, 'vengeance');
expect(history.reads, ['vengeance']);
history.reads.clear();
sync(_drama('other'), active: true, wasActive: true, dramaChanged: false);
expect(logic.drama.id, 'vengeance');
expect(history.reads, isEmpty);
expect(logic.recent, isEmpty);
expect(logic.recentError, 'progressError');
logic.dispose();
});
test(
'should_reset_section_when_section_request_arrives_while_page_is_inactive',
() {
final logic = MineLogic(
history: FakeWatchHistory(),
drama: _drama('s'),
initialSection: 0,
);
test('should_refresh_recent_only_when_page_becomes_active', () async {
final history = FakeWatchHistory()..recentRecords = const [_record];
final logic = MineLogic(history: history);
await pumpEventQueue();
logic.syncPage(
active: false,
wasActive: false,
nextDrama: logic.drama,
dramaChanged: false,
sectionRequest: 1,
previousSectionRequest: 0,
initialSection: 1,
);
history.recentRecords = const [];
logic.syncPage(active: true, wasActive: true);
await pumpEventQueue();
expect(logic.recent, hasLength(1));
expect(logic.section, 1);
logic.dispose();
},
);
logic.syncPage(active: true, wasActive: false);
await pumpEventQueue();
expect(logic.recent, isEmpty);
logic.dispose();
});
testWidgets('should_return_choice_and_forward_it_when_act_runs_embedded', (
testWidgets('should_forward_action_when_embedded_or_pop_when_standalone', (
tester,
) async {
final logic = MineLogic(
history: FakeWatchHistory(),
drama: _drama('s'),
initialSection: 0,
);
final logic = MineLogic(history: FakeWatchHistory());
final forwarded = <MyPageAction>[];
final popped = Completer<MyPageAction?>();
late BuildContext pageContext;
@@ -191,7 +86,6 @@ void main() {
),
),
);
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
@@ -202,72 +96,15 @@ void main() {
onAction: forwarded.add,
);
expect(forwarded, [MyPageAction.discover]);
expect(Navigator.of(pageContext).canPop(), isTrue);
logic.act(pageContext, MyPageAction.resume, embedded: false);
await tester.pumpAndSettle();
await expectLater(popped.future, completion(MyPageAction.resume));
logic.dispose();
});
testWidgets('should_switch_app_locale_when_language_sheet_is_used', (
tester,
) async {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.empty();
final locale = LocaleController();
final logic = MineLogic(
history: FakeWatchHistory(),
drama: _drama('s'),
initialSection: 0,
);
late BuildContext context;
await tester.pumpWidget(
ChangeNotifierProvider<LocaleController>.value(
value: locale,
child: MaterialApp(
locale: const Locale('en'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Builder(
builder: (inner) {
context = inner;
return const Scaffold(body: SizedBox.shrink());
},
),
),
),
);
unawaited(logic.showLanguage(context));
await tester.pumpAndSettle();
final l10n = AppLocalizations.of(context)!;
expect(find.text(l10n.english), findsOneWidget);
expect(find.text(l10n.simplifiedChinese), findsOneWidget);
expect(find.text('Bahasa Indonesia'), findsOneWidget);
await tester.tap(find.text('Bahasa Indonesia'));
await tester.pumpAndSettle();
expect(locale.locale, const Locale('id'));
unawaited(logic.showLanguage(context));
await tester.pumpAndSettle();
await tester.tap(find.text(l10n.simplifiedChinese));
await tester.pumpAndSettle();
expect(locale.locale, const Locale('zh'));
logic.dispose();
});
test('should_do_nothing_when_notifying_after_dispose', () {
final logic = MineLogic(
history: FakeWatchHistory(),
drama: _drama('s'),
initialSection: 0,
);
final logic = MineLogic(history: FakeWatchHistory());
var notifications = 0;
logic.addListener(() => notifications++);
+18 -56
View File
@@ -2,15 +2,13 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
import 'package:fruitshorts/app.dart';
import 'package:fruitshorts/core/data/models/watch_progress.dart';
import 'package:fruitshorts/core/data/repositories/favorite_repository.dart';
import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart';
import 'package:fruitshorts/core/theme/app_colors.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';
@@ -20,7 +18,7 @@ import '../support/fake_notice_repository.dart';
import '../support/widget_player.dart';
void main() {
testWidgets('should_render_me_user_wallet_continue_and_language_rows', (
testWidgets('should_render_only_continue_and_six_core_entries', (
tester,
) async {
SharedPreferencesAsyncPlatform.instance =
@@ -59,58 +57,22 @@ void main() {
await tester.pumpAndSettle();
expect(find.byKey(const Key('mine-user-row')), findsOneWidget);
expect(find.byKey(const Key('mine-wallet')), findsOneWidget);
for (final key in const [Key('mine-search-button'), Key('mine-avatar')]) {
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('mine-continue-watching')), findsOneWidget);
expect(find.byKey(const Key('mine-language')), findsOneWidget);
expect(find.byKey(const Key('mine-library-tab-0')), findsOneWidget);
expect(find.byKey(const Key('mine-library-tab-1')), findsOneWidget);
expect(find.byKey(const Key('mine-library-tab-2')), findsOneWidget);
final tab0 = find.byKey(const Key('mine-library-tab-0'));
final tab1 = find.byKey(const Key('mine-library-tab-1'));
final tab2 = find.byKey(const Key('mine-library-tab-2'));
expect(tester.getTopLeft(tab0).dx, lessThan(tester.getTopLeft(tab1).dx));
expect(tester.getTopLeft(tab1).dx, lessThan(tester.getTopLeft(tab2).dx));
expect(
tester.getTopLeft(tab1).dx - tester.getTopRight(tab0).dx,
closeTo(20, 1),
);
expect(
(tester
.widget<AnimatedContainer>(
find.descendant(
of: tab0,
matching: find.byType(AnimatedContainer),
),
)
.decoration
as BoxDecoration)
.color,
AppColors.blue,
);
expect(
(tester
.widget<AnimatedContainer>(
find.descendant(
of: tab1,
matching: find.byType(AnimatedContainer),
),
)
.decoration
as BoxDecoration)
.color,
Colors.transparent,
);
expect(find.byKey(const Key('mine-continue-play')), findsOneWidget);
for (final key in const [
Key('mine-watchlist'),
Key('mine-history'),
Key('mine-downloads'),
Key('mine-wallet'),
Key('mine-vip'),
Key('mine-settings'),
]) {
expect(find.byKey(key), findsOneWidget);
}
expect(find.byKey(const Key('mine-language')), findsNothing);
expect(find.byKey(const Key('mine-library-tab-0')), findsNothing);
expect(find.byKey(const Key('mine-library-tab-1')), findsNothing);
expect(find.byKey(const Key('mine-library-tab-2')), findsNothing);
expect(tester.takeException(), isNull);
});
}
@@ -1,73 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
import 'package:fruitshorts/core/config/locale_controller.dart';
import 'package:fruitshorts/core/data/models/drama.dart';
import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart';
import 'package:fruitshorts/l10n/app_localizations.dart';
import 'package:fruitshorts/pages/mine/mine_logic.dart';
const _drama = Drama(
id: 'drama-1',
title: 'The Signal',
synopsis: 'synopsis',
posterUrl: '/files/1.jpg',
);
void main() {
setUp(() {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.empty();
});
testWidgets('should_not_show_server_address_in_settings_sheet', (
tester,
) async {
await _pumpSettings(tester);
expect(find.byKey(const Key('server-endpoint-setting')), findsNothing);
expect(find.text('Server address'), findsNothing);
});
testWidgets('should_keep_all_three_languages_in_settings_sheet', (
tester,
) async {
await _pumpSettings(tester);
expect(find.text('English'), findsOneWidget);
expect(find.text('简体中文'), findsOneWidget);
expect(find.text('Bahasa Indonesia'), findsOneWidget);
});
}
Future<void> _pumpSettings(WidgetTester tester) async {
final logic = MineLogic(
history: InMemoryWatchHistoryRepository(),
drama: _drama,
initialSection: 0,
);
addTearDown(logic.dispose);
await tester.pumpWidget(
ChangeNotifierProvider(
create: (_) => LocaleController(),
child: MaterialApp(
locale: const Locale('en'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Builder(
builder: (context) => Scaffold(
body: TextButton(
key: const Key('open-settings'),
onPressed: () => logic.showLanguage(context),
child: const Text('Open settings'),
),
),
),
),
),
);
await tester.tap(find.byKey(const Key('open-settings')));
await tester.pumpAndSettle();
}
@@ -2,19 +2,15 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
import 'package:fruitshorts/app.dart';
import 'package:fruitshorts/core/data/models/content_category.dart';
import 'package:fruitshorts/core/data/models/drama.dart';
import 'package:fruitshorts/core/data/models/home_feed.dart';
import 'package:fruitshorts/core/data/repositories/category_repository.dart';
import 'package:fruitshorts/core/data/repositories/favorite_repository.dart';
import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart';
import 'package: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_interaction_repositories.dart';
import '../support/fake_notice_repository.dart';
import '../support/widget_player.dart';
@@ -25,19 +21,20 @@ void main() {
InMemorySharedPreferencesAsync.empty();
});
testWidgets('should_filter_theater_grid_when_language_chip_is_tapped', (
testWidgets('should_open_minimal_settings_without_language_switches', (
tester,
) async {
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final catalog = FakeCatalogRepository();
await tester.pumpWidget(
LumaDramaApp(
playerFactory: createWidgetPlayer,
catalogRepository: FakeCatalogRepository(),
categoryRepository: _MixedLanguageCategoryRepository(),
catalogRepository: catalog,
categoryRepository: FakeCategoryRepository(catalog: catalog),
watchHistoryRepository: InMemoryWatchHistoryRepository(),
favoriteRepository: InMemoryFavoriteRepository(),
playbackRepository: FakePlaybackRepository(),
@@ -47,44 +44,17 @@ void main() {
),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('tab-discover')));
await tester.tap(find.byKey(const Key('tab-my')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('mine-settings')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('home-drama-card-zh-demo')), findsOneWidget);
expect(find.byKey(const Key('home-drama-card-id-demo')), findsOneWidget);
await tester.tap(find.byKey(const Key('discover-language-zh-CN')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('home-drama-card-zh-demo')), findsOneWidget);
expect(find.byKey(const Key('home-drama-card-id-demo')), findsNothing);
expect(find.byKey(const Key('settings-page')), findsOneWidget);
expect(find.byKey(const Key('settings-notices')), findsOneWidget);
expect(find.byKey(const Key('server-endpoint-setting')), findsNothing);
expect(find.text('Server address'), findsNothing);
expect(find.text('English'), findsNothing);
expect(find.text('简体中文'), findsNothing);
expect(find.text('Bahasa Indonesia'), findsNothing);
});
}
class _MixedLanguageCategoryRepository implements CategoryRepository {
@override
Future<List<ContentCategory>> fetchCategories() async => const [];
@override
Future<HomeFeed> fetchDiscoverFeed() async => HomeFeed(
sections: [
FeedSection(
code: 'hot',
title: 'Hot',
dramas: [
_drama('zh-demo', 'Chinese Demo', 'zh-CN'),
_drama('id-demo', 'Indonesian Demo', 'id'),
],
),
],
);
}
Drama _drama(String id, String title, String language) => Drama(
id: id,
title: title,
synopsis: '$title synopsis',
posterUrl: '/files/$id.png',
language: language,
detailLoaded: true,
);
+3 -3
View File
@@ -46,11 +46,11 @@ void main() {
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('tab-my')));
await tester.pumpAndSettle();
await tester.drag(find.byType(ListView), const Offset(0, -520));
await tester.tap(find.byKey(const Key('mine-settings')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('mine-notices')), findsOneWidget);
await tester.tap(find.byKey(const Key('mine-notices')));
expect(find.byKey(const Key('settings-notices')), findsOneWidget);
await tester.tap(find.byKey(const Key('settings-notices')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('notices-page')), findsOneWidget);
+1 -1
View File
@@ -3,7 +3,7 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/models/drama.dart';
import 'package:fruitshorts/core/data/models/home_feed.dart';
import 'package:fruitshorts/pages/ranking/ranking_logic.dart';
import 'package:fruitshorts/pages/discover/ranking_logic.dart';
void main() {
test('should_use_backend_hot_order_when_hot_board_is_selected', () {
-88
View File
@@ -1,88 +0,0 @@
// 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/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: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.withData({});
});
testWidgets('should_render_real_section_order_when_ranking_tab_is_opened', (
tester,
) async {
await _pumpApp(tester);
await tester.tap(find.byKey(const Key('tab-ranking')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('ranking-page')), findsOneWidget);
expect(find.byKey(const Key('ranking-tab-hot')), findsOneWidget);
expect(find.byKey(const Key('ranking-tab-new')), findsOneWidget);
expect(
find.byKey(const Key('ranking-entry-the-signal-demo')),
findsOneWidget,
);
expect(
find.byKey(const Key('ranking-entry-city-lights-demo')),
findsOneWidget,
);
await tester.tap(find.byKey(const Key('ranking-tab-new')));
await tester.pumpAndSettle();
expect(
find.byKey(const Key('ranking-entry-the-signal-demo')),
findsOneWidget,
);
});
testWidgets('should_open_the_selected_drama_when_ranking_entry_is_tapped', (
tester,
) async {
await _pumpApp(tester);
await tester.tap(find.byKey(const Key('tab-ranking')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('ranking-entry-city-lights-demo')));
await tester.pumpAndSettle();
expect(find.byType(DramaDetailPage), findsOneWidget);
expect(find.text('City Lights & Velvet Nights'), findsWidgets);
});
}
Future<void> _pumpApp(WidgetTester tester) async {
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final catalog = FakeCatalogRepository();
await tester.pumpWidget(
LumaDramaApp(
playerFactory: createWidgetPlayer,
catalogRepository: catalog,
categoryRepository: FakeCategoryRepository(catalog: catalog),
watchHistoryRepository: InMemoryWatchHistoryRepository(),
favoriteRepository: InMemoryFavoriteRepository(cards: catalog.dramas),
playbackRepository: FakePlaybackRepository(),
commentRepository: FakeCommentRepository(),
interactionRepository: FakeInteractionRepository(),
haptics: FakeHaptics(),
),
);
await tester.pumpAndSettle();
}
+13 -35
View File
@@ -150,7 +150,6 @@ void main() {
isFalse,
);
expect(find.text('Quick access'), findsOneWidget);
expect(find.text('Bahasa Indonesia'), findsOneWidget);
expect(find.text('7 EPISODES'), findsOneWidget);
expect(tester.takeException(), isNull);
@@ -181,7 +180,7 @@ void main() {
},
);
testWidgets('should_change_interface_language_when_locale_is_picked', (
testWidgets('should_follow_system_locale_without_language_switch', (
tester,
) async {
tester.view.physicalSize = const Size(390, 844);
@@ -196,20 +195,13 @@ void main() {
await tester.pumpAndSettle();
expect(find.text('Vicky sheng'), findsOneWidget);
expect(find.text('Coins'), findsOneWidget);
await tester.drag(find.byType(ListView), const Offset(0, -700));
await tester.pumpAndSettle();
await tester.tap(find.text('App Language'));
await tester.pumpAndSettle();
await tester.tap(find.text('简体中文').last);
await tester.pumpAndSettle();
expect(find.text('我的'), findsWidgets);
await tester.drag(find.byType(ListView), const Offset(0, 700));
await tester.pumpAndSettle();
expect(find.text('App Language'), findsNothing);
expect(find.text('简体中文'), findsNothing);
expect(find.byKey(const Key('mine-wallet')), findsOneWidget);
expect(find.text('首页'), findsOneWidget);
await tester.tap(find.text('发现'));
expect(find.text('Home'), findsWidgets);
await tester.tap(find.byKey(const Key('tab-discover')));
await tester.pumpAndSettle();
expect(find.text('推荐'), findsOneWidget);
expect(find.text('Discover'), findsWidgets);
});
testWidgets(
@@ -297,7 +289,8 @@ void main() {
await tester.tap(find.byKey(const Key('tab-discover')));
await tester.pumpAndSettle();
expect(find.text('Recommended'), findsOneWidget);
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(
@@ -319,11 +312,7 @@ void main() {
await tester.tap(find.byKey(const Key('tab-my')));
await tester.pumpAndSettle();
await tester.drag(find.byType(ListView), const Offset(0, -320));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('mine-library-tab-1')));
await tester.pumpAndSettle();
expect(find.text('No shows here yet.'), findsOneWidget);
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);
@@ -353,16 +342,11 @@ void main() {
await tester.tap(find.byKey(const Key('tab-my')));
await tester.pumpAndSettle();
await tester.drag(find.byType(ListView), const Offset(0, -700));
await tester.pumpAndSettle();
expect(find.text('History'), findsWidgets);
expect(find.byKey(const Key('mine-history')), findsOneWidget);
expect(
Navigator.of(tester.element(find.text('History').first)).canPop(),
Navigator.of(tester.element(find.byKey(const Key('mine-menu')))).canPop(),
isFalse,
);
await tester.tap(find.byKey(const Key('mine-library-tab-1')));
await tester.pumpAndSettle();
expect(find.text('No shows here yet.'), findsOneWidget);
await tester.tap(find.byKey(const Key('tab-discover')));
await tester.pumpAndSettle();
@@ -376,11 +360,7 @@ void main() {
);
await tester.tap(find.byKey(const Key('tab-my')));
await tester.pumpAndSettle();
await tester.drag(find.byType(ListView), const Offset(0, -400));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('mine-library-tab-1')));
await tester.pumpAndSettle();
expect(find.text('No shows here yet.'), findsOneWidget);
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);
@@ -625,9 +605,7 @@ void main() {
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('tab-my')));
await tester.pumpAndSettle();
await tester.drag(find.byType(ListView), const Offset(0, -320));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('mine-library-tab-1')));
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);