diff --git a/lib/app.dart b/lib/app.dart index 84f1e7f..b697138 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -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 { 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( - 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( - 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( + value: SystemUiOverlayStyle.light.copyWith( + statusBarColor: Colors.transparent, + systemNavigationBarColor: AppColors.bar, + systemNavigationBarIconBrightness: Brightness.light, ), - home: const HomePage(), + child: child ?? const SizedBox.shrink(), ), + home: const HomePage(), ), ); } diff --git a/lib/core/config/app_locale.dart b/lib/core/config/app_locale.dart new file mode 100644 index 0000000..5e5b725 --- /dev/null +++ b/lib/core/config/app_locale.dart @@ -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'), + }; +} diff --git a/lib/core/config/locale_controller.dart b/lib/core/config/locale_controller.dart deleted file mode 100644 index 9404b4c..0000000 --- a/lib/core/config/locale_controller.dart +++ /dev/null @@ -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 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 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. - } - } -} diff --git a/lib/core/data/services/category_controller.dart b/lib/core/data/services/category_controller.dart index efeada2..026f17f 100644 --- a/lib/core/data/services/category_controller.dart +++ b/lib/core/data/services/category_controller.dart @@ -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 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 get visibleDramas { - if (selectedCode == allCode) return feed.dramas; - return _selectedSection?.dramas ?? const []; - } - - FeedSection? get _selectedSection { + /// 分类九宫格点击后读取该分类的剧目;缺失分区返回空列表。 + List 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(); } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 94333ca..95aa570 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -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" } } } } diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index c8b540d..b30bb89 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -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" } } } } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 0a9e9d1..c814895 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -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: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index b45cf0c..b33f730 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -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'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 6004c6a..88bef94 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -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'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 53e7a74..6a31d17 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -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 个结果'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index f637bac..f63ec02 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -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" } } } } diff --git a/lib/pages/discover/category_dramas_page.dart b/lib/pages/discover/category_dramas_page.dart new file mode 100644 index 0000000..385a52c --- /dev/null +++ b/lib/pages/discover/category_dramas_page.dart @@ -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 dramas; + final ValueChanged? 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( + MaterialPageRoute(builder: (_) => DramaDetailPage(drama: drama)), + ); + } +} diff --git a/lib/pages/discover/discover_binding.dart b/lib/pages/discover/discover_binding.dart index 0f62d70..ec1e157 100644 --- a/lib/pages/discover/discover_binding.dart +++ b/lib/pages/discover/discover_binding.dart @@ -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); } diff --git a/lib/pages/discover/discover_logic.dart b/lib/pages/discover/discover_logic.dart index 55e5972..7df910f 100644 --- a/lib/pages/discover/discover_logic.dart +++ b/lib/pages/discover/discover_logic.dart @@ -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 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 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 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 visibleDramas(List fallback) { - if (!hasSearchQuery) return _filterByLanguage(fallback); - return _filterByLanguage(searchResults); - } + void openSearch() => searchFocus.requestFocus(); - /// 当前 feed 中出现过的语种,按首次出现顺序去重。 - List languagesOf(List dramas) { - final languages = []; - 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 _filterByLanguage(List dramas) { - final code = selectedLanguage; - if (code == null) return dramas; - return dramas.where((drama) => drama.language == code).toList(); - } - - Future 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 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 dramas) { - for (final item in dramas) { - if (item.id == dramaId) return item; - } - return null; - } - - void openCard( - BuildContext context, - Drama? selected, { - required bool embedded, - ValueChanged? onDramaSelected, - ValueChanged? 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; diff --git a/lib/pages/discover/discover_types.dart b/lib/pages/discover/discover_types.dart new file mode 100644 index 0000000..a609965 --- /dev/null +++ b/lib/pages/discover/discover_types.dart @@ -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? onAction; + final ValueChanged? onDramaSelected; + final VoidCallback? onProfile; + + bool get embedded => onAction != null; +} diff --git a/lib/pages/discover/discover_view.dart b/lib/pages/discover/discover_view.dart index 35f0198..bfa0d41 100644 --- a/lib/pages/discover/discover_view.dart +++ b/lib/pages/discover/discover_view.dart @@ -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? onAction; - final List? dramas; - final ValueChanged? onDramaSelected; - final List banners; - final ValueChanged? onBannerSelected; + final bool focusSearch; + final DiscoverPageCallbacks callbacks; @override State createState() => _DiscoverPageState(); @@ -72,32 +53,28 @@ class DiscoverPage extends StatefulWidget { class _DiscoverPageState extends State { 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(), - search: widget.searchRepository ?? context.read(), - ); - if (widget.focusSearch) { - logic.searchOpen = true; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted && widget.active) logic.searchFocus.requestFocus(); - }); - } + logic = DiscoverBinding().create(search: context.read()); + 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 { Widget _page(BuildContext context) { final l10n = AppLocalizations.of(context)!; final categories = context.watch(); - final allDramas = [...?widget.dramas, ...categories.visibleDramas]; - final visible = logic.visibleDramas(categories.visibleDramas); + final banners = context.watch().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 { 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 { Widget _feed( AppLocalizations l10n, CategoryController categories, - List allDramas, - List 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 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 _content( + AppLocalizations l10n, + CategoryController categories, + List 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 _theaterContent( + AppLocalizations l10n, + CategoryController categories, + List 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 _theaterBlocks( + AppLocalizations l10n, + CategoryController categories, + List 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 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 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( + 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( + MaterialPageRoute(builder: (_) => DramaDetailPage(drama: drama)), + ); + } + + Future _openBanner(FeedBanner banner) async { + final dramaId = banner.dramaId; + if (dramaId == null) return; + for (final drama in context.read().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().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 { 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 { 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'; -} diff --git a/lib/pages/ranking/ranking_logic.dart b/lib/pages/discover/ranking_logic.dart similarity index 100% rename from lib/pages/ranking/ranking_logic.dart rename to lib/pages/discover/ranking_logic.dart diff --git a/lib/pages/discover/widgets/category_grid.dart b/lib/pages/discover/widgets/category_grid.dart new file mode 100644 index 0000000..63ae42f --- /dev/null +++ b/lib/pages/discover/widgets/category_grid.dart @@ -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 _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 categories; + final ValueChanged 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, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/pages/discover/widgets/discover_header.dart b/lib/pages/discover/widgets/discover_header.dart new file mode 100644 index 0000000..1d20061 --- /dev/null +++ b/lib/pages/discover/widgets/discover_header.dart @@ -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), + ), + ); + } +} diff --git a/lib/pages/discover/widgets/discover_search_field.dart b/lib/pages/discover/widgets/discover_search_field.dart new file mode 100644 index 0000000..de549af --- /dev/null +++ b/lib/pages/discover/widgets/discover_search_field.dart @@ -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 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), + ), + ), + ), + ), + ); + } +} diff --git a/lib/pages/discover/widgets/discover_search_results.dart b/lib/pages/discover/widgets/discover_search_results.dart new file mode 100644 index 0000000..dfaa0d5 --- /dev/null +++ b/lib/pages/discover/widgets/discover_search_results.dart @@ -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 results; + final VoidCallback onRetry; + final ValueChanged 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); + } +} diff --git a/lib/pages/discover/widgets/drama_grid.dart b/lib/pages/discover/widgets/drama_grid.dart new file mode 100644 index 0000000..7974b5d --- /dev/null +++ b/lib/pages/discover/widgets/drama_grid.dart @@ -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 dramas; + final ValueChanged 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 = []; + 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, + ), + ), + ), + ); + } +} diff --git a/lib/pages/discover/widgets/home_drama_grid.dart b/lib/pages/discover/widgets/home_drama_grid.dart deleted file mode 100644 index f1ca78a..0000000 --- a/lib/pages/discover/widgets/home_drama_grid.dart +++ /dev/null @@ -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 dramas; - final bool searching; - final bool searchLoading; - final String? searchError; - final VoidCallback onRetrySearch; - final ValueChanged 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 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 items, int start) { - final children = []; - 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, - ), - ), - ), - ); - } -} diff --git a/lib/pages/discover/widgets/ranking_section.dart b/lib/pages/discover/widgets/ranking_section.dart new file mode 100644 index 0000000..9dbe2ef --- /dev/null +++ b/lib/pages/discover/widgets/ranking_section.dart @@ -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 sections; + final List fallback; + final ValueChanged onOpen; + + @override + State createState() => _RankingSectionState(); +} + +class _RankingSectionState extends State { + 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, + ), + ), + ); + } +} diff --git a/lib/pages/home/home_logic.dart b/lib/pages/home/home_logic.dart index b3b5781..8834669 100644 --- a/lib/pages/home/home_logic.dart +++ b/lib/pages/home/home_logic.dart @@ -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 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 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 openDramaById(BuildContext context, String dramaId) async { - try { - final drama = await context.read().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 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(builder: (_) => const FavoritesPage())); + } + + void openHistory(BuildContext context) { + Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => const WatchRecordsPage())); + } + void myAction(MyPageAction action) { switch (action) { case MyPageAction.home: diff --git a/lib/pages/home/home_view.dart b/lib/pages/home/home_view.dart index e522637..5fadb0b 100644 --- a/lib/pages/home/home_view.dart +++ b/lib/pages/home/home_view.dart @@ -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 dramas; - final List sections; - final List 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().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(), - downloads: context.read(), - ), - 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().signOut(), - wallet: context.read(), - ), - 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(), + downloads: context.read(), + ); + } + + 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().selectionClick()); } - if (tab == 2) { - myProfile ? logic.openProfile() : logic.openLibrary(); - } else { - logic.select(tab); - } + logic.select(tab); } Future _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); diff --git a/lib/pages/home/widgets/home_menu_sheet.dart b/lib/pages/home/widgets/home_menu_sheet.dart index e870bd4..0a255b3 100644 --- a/lib/pages/home/widgets/home_menu_sheet.dart +++ b/lib/pages/home/widgets/home_menu_sheet.dart @@ -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().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), ), ); } diff --git a/lib/pages/mine/mine_binding.dart b/lib/pages/mine/mine_binding.dart index 6c684ec..83017a5 100644 --- a/lib/pages/mine/mine_binding.dart +++ b/lib/pages/mine/mine_binding.dart @@ -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); } diff --git a/lib/pages/mine/mine_logic.dart b/lib/pages/mine/mine_logic.dart index f6e17fd..b60be77 100644 --- a/lib/pages/mine/mine_logic.dart +++ b/lib/pages/mine/mine_logic.dart @@ -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 position; List 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 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 showLanguage(BuildContext context) async { - final l10n = AppLocalizations.of(context)!; - await showModalBottomSheet( - 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().select(code); - Navigator.pop(sheetContext); - }, - ), - const SizedBox(height: 8), - ], - ), - ), - ), - ), - ); - } - @override void dispose() { _disposed = true; diff --git a/lib/pages/mine/mine_types.dart b/lib/pages/mine/mine_types.dart new file mode 100644 index 0000000..192bd47 --- /dev/null +++ b/lib/pages/mine/mine_types.dart @@ -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? onAction; + final ValueChanged? onDramaSelected; + + bool get embedded => onAction != null; +} diff --git a/lib/pages/mine/mine_view.dart b/lib/pages/mine/mine_view.dart index 0e2d6cf..92e41bb 100644 --- a/lib/pages/mine/mine_view.dart +++ b/lib/pages/mine/mine_view.dart @@ -5,67 +5,42 @@ import 'dart:async'; 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/data/services/auth_controller.dart'; -import '../../core/data/services/favorite_controller.dart'; -import '../../core/data/services/interaction_controller.dart'; +import '../../core/data/services/catalog_controller.dart'; +import '../../core/data/services/download_controller.dart'; import '../../core/data/services/wallet_controller.dart'; -import '../../core/formatting/error_text.dart'; -import '../../core/formatting/localized_content.dart'; import '../../core/theme/app_colors.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 '../../core/widgets/auth_gate.dart'; import '../../l10n/app_localizations.dart'; -import '../account/account_entry_card.dart'; import '../downloads/downloads_page.dart'; import '../favorites/favorites_page.dart'; -import '../notices/notices_page.dart'; +import '../player/player_view.dart'; +import '../settings/settings_page.dart'; import '../store/vip_page.dart'; import '../wallet/wallet_page.dart'; import '../watch_records/watch_records_page.dart'; import 'mine_binding.dart'; import 'mine_logic.dart'; +import 'mine_types.dart'; +import 'widgets/continue_watching_card.dart'; +import 'widgets/mine_header.dart'; +import 'widgets/mine_menu.dart'; +/// 极简「我的」页:续看 + 追剧 / 历史 / 下载 / 钱包 / 会员 / 设置。 class MyPage extends StatefulWidget { const MyPage({ super.key, - required this.drama, - required this.duration, - this.initialSection = 0, - this.profile = false, - this.history, - this.embedded = false, this.active = true, - this.sectionRequest = 0, - this.onAction, - this.signedIn = false, - this.onSignIn, - this.onSignOut, - this.dramas, - this.onDramaSelected, - this.wallet, + this.callbacks = const MyPageCallbacks(), }); - final Drama drama; - final Duration duration; - final int initialSection; - final bool profile; - final WatchHistoryRepository? history; - final bool embedded; final bool active; - final int sectionRequest; - final ValueChanged? onAction; - final bool signedIn; - final VoidCallback? onSignIn; - final VoidCallback? onSignOut; - final List? dramas; - final ValueChanged? onDramaSelected; - final WalletController? wallet; + final MyPageCallbacks callbacks; @override State createState() => _MyPageState(); @@ -74,28 +49,20 @@ class MyPage extends StatefulWidget { class _MyPageState extends State { late final MineLogic logic; + bool get embedded => widget.callbacks.embedded; + @override void initState() { super.initState(); logic = MineBinding().create( - drama: widget.drama, - initialSection: widget.initialSection, - history: widget.history ?? context.read(), + history: context.read(), ); } @override void didUpdateWidget(covariant MyPage oldWidget) { super.didUpdateWidget(oldWidget); - logic.syncPage( - active: widget.active, - wasActive: oldWidget.active, - nextDrama: widget.drama, - dramaChanged: oldWidget.drama.id != widget.drama.id, - sectionRequest: widget.sectionRequest, - previousSectionRequest: oldWidget.sectionRequest, - initialSection: widget.initialSection, - ); + logic.syncPage(active: widget.active, wasActive: oldWidget.active); } @override @@ -115,258 +82,53 @@ class _MyPageState extends State { Widget _page(BuildContext context) { final l10n = AppLocalizations.of(context)!; final content = SafeArea( - bottom: !widget.embedded, + bottom: !embedded, child: Column( children: [ _topBar(l10n), - Expanded( - child: ListView( - padding: const EdgeInsets.fromLTRB(14, 2, 14, 18), - children: [ - _userRow(l10n), - const SizedBox(height: 12), - _wallet(l10n), - if (_continueDrama != null) ...[ - const SizedBox(height: 12), - _continueCard(l10n, _continueDrama!, _continueRecord!), - ], - const SizedBox(height: 14), - _library(l10n), - const SizedBox(height: 14), - _languageRow(l10n), - if (widget.signedIn) ...[ - const SizedBox(height: 12), - const AccountEntryCard(), - ], - const SizedBox(height: 12), - _menuCard(l10n), - ], - ), - ), - if (!widget.embedded) _bottomNav(l10n), + Expanded(child: _body(context, l10n)), + if (!embedded) _bottomNav(l10n), ], ), ); return Theme( data: AppTheme.dark(), - child: widget.embedded + child: embedded ? content : Scaffold(backgroundColor: AppColors.ink, body: content), ); } - Widget _topBar(AppLocalizations l10n) => 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, - ), - ), - const SizedBox(width: 8), - Text( - l10n.myTab, - style: const TextStyle( - fontFamily: 'Outfit', - color: AppColors.onSurface, - fontSize: 15, - fontWeight: FontWeight.w800, - ), - ), - const Spacer(), - _RoundHeaderButton( - key: const Key('mine-search-button'), - tooltip: l10n.search, - icon: Icons.search_rounded, - onTap: () => logic.act( - context, - MyPageAction.discover, - embedded: widget.embedded, - onAction: widget.onAction, - ), - ), - const SizedBox(width: 8), - Container( - key: const Key('mine-avatar'), - 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, - ), - ), - ], - ), - ), - ); - - Widget _userRow(AppLocalizations l10n) { - final user = widget.signedIn ? context.watch().user : null; - final name = widget.signedIn - ? ((user?.nickname.isNotEmpty ?? false) - ? user!.nickname - : l10n.localViewer) - : l10n.localViewer; - final subtitle = widget.signedIn - ? ((user?.uid.isNotEmpty ?? false) - ? 'ID: ${user!.uid}' - : l10n.myAccount) - : l10n.logInHint; - return Row( - key: const Key('mine-user-row'), - children: [ - Container( - width: 48, - height: 48, - decoration: const BoxDecoration( - shape: BoxShape.circle, - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [Color(0xFF6A5CFF), AppColors.blue], - ), - ), - child: const Icon( - Icons.person_rounded, - color: AppColors.onSurface, - size: 22, - ), - ), - const SizedBox(width: 10), - Expanded( - child: 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), - ), - ], - ), - ), - if (widget.signedIn) - TextButton( - onPressed: widget.onSignOut, - style: TextButton.styleFrom( - foregroundColor: AppColors.muted, - backgroundColor: const Color(0xFF24252A), - minimumSize: const Size(52, 30), - padding: const EdgeInsets.symmetric(horizontal: 10), - shape: const StadiumBorder(), - textStyle: const TextStyle(fontSize: 10), - ), - child: Text(l10n.signOut), - ) - else - TextButton( - key: const Key('mine-sign-in'), - onPressed: widget.onSignIn, - style: TextButton.styleFrom( - foregroundColor: AppColors.cream, - backgroundColor: const Color(0xFF24252A), - minimumSize: const Size(64, 30), - padding: const EdgeInsets.symmetric(horizontal: 10), - shape: const StadiumBorder(), - textStyle: const TextStyle(fontSize: 10), - ), - child: Text('${l10n.logIn}/${l10n.signUp}'), - ), - ], - ); - } - - Widget _wallet(AppLocalizations l10n) { - final wallet = widget.wallet; - if (wallet == null) return _walletCard(l10n, null); - return ListenableBuilder( - listenable: wallet, - builder: (context, _) => _walletCard(l10n, wallet), - ); - } - - Widget _walletCard(AppLocalizations l10n, WalletController? wallet) { - final value = wallet == null - ? l10n.coins - : l10n.walletBalanceShort(wallet.balance); - return InkWell( - key: const Key('mine-wallet'), - onTap: () => _openWallet(context), - borderRadius: BorderRadius.circular(13), - child: Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: AppColors.goldCard, - borderRadius: BorderRadius.circular(13), - ), + Widget _topBar(AppLocalizations l10n) { + return SizedBox( + height: 46, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14), child: Row( children: [ - const Icon( - Icons.monetization_on_rounded, - color: AppColors.gold, - size: 20, - ), - const SizedBox(width: 9), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - l10n.coins, - style: const TextStyle( - color: AppColors.onSurface, - fontSize: 12, - fontWeight: FontWeight.w800, - ), - ), - const SizedBox(height: 2), - Text( - widget.signedIn ? value : l10n.logInHint, - style: const TextStyle( - color: AppColors.accent, - fontSize: 9, - ), - ), - ], + 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 Icon( - Icons.chevron_right_rounded, - color: Color(0xFF666666), - size: 16, + const SizedBox(width: 8), + Text( + l10n.myTab, + style: const TextStyle( + fontFamily: 'Outfit', + color: AppColors.onSurface, + fontSize: 15, + fontWeight: FontWeight.w800, + ), ), ], ), @@ -374,29 +136,41 @@ class _MyPageState extends State { ); } - void _openVip() { - if (!widget.signedIn) { - widget.onSignIn?.call(); - return; - } - final wallet = widget.wallet; - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => VipPage( - onPurchased: wallet == null ? null : () => unawaited(wallet.load()), + Widget _body(BuildContext context, AppLocalizations l10n) { + final auth = context.watch(); + final wallet = context.watch(); + final record = _continueRecord; + final drama = _dramaFor(record); + return ListView( + padding: const EdgeInsets.only(bottom: 18), + children: [ + const MineHeader(), + if (drama != null && record != null) + ContinueWatchingCard( + drama: drama, + record: record, + onResume: () => _resume(drama), + ), + const SizedBox(height: 14), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 14), + child: MineMenu( + walletValue: _walletValue(l10n, auth, wallet), + onOpen: _openDestination, + ), ), - ), + ], ); } - void _openWallet(BuildContext context) { - if (!widget.signedIn) { - widget.onSignIn?.call(); - return; - } - Navigator.of( - context, - ).push(MaterialPageRoute(builder: (_) => const WalletPage())); + String _walletValue( + AppLocalizations l10n, + AuthController auth, + WalletController wallet, + ) { + if (!auth.isSignedIn) return l10n.logInHint; + if (wallet.snapshot == null) return l10n.coins; + return l10n.walletBalanceShort(wallet.balance); } WatchProgress? get _continueRecord { @@ -406,617 +180,101 @@ class _MyPageState extends State { return null; } - Drama? get _continueDrama { - final record = _continueRecord; + Drama? _dramaFor(WatchProgress? record) { if (record == null) return null; - for (final drama in [...?widget.dramas, widget.drama]) { + for (final drama in context.read().dramas) { if (drama.id == record.dramaId) return drama; } return null; } - Widget _continueCard( - AppLocalizations l10n, - Drama drama, - WatchProgress record, - ) { - return Container( - key: const Key('mine-continue-watching'), - padding: const EdgeInsets.all(9), - decoration: BoxDecoration( - color: AppColors.card, - borderRadius: BorderRadius.circular(12), - ), - child: Row( - children: [ - _poster(drama, width: 50, height: 64, radius: 7), - const SizedBox(width: 9), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - l10n.continueWatching, - style: const TextStyle( - color: AppColors.accent, - fontSize: 9, - fontWeight: FontWeight.w800, - ), - ), - const SizedBox(height: 2), - Text( - localizedDramaTitle(l10n, drama), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.onSurface, - fontSize: 12, - fontWeight: FontWeight.w800, - ), - ), - const SizedBox(height: 2), - Text( - '${_episodeText(drama, record)} · ' - '${_clock(record.position)} / ${_clock(record.duration)}', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(color: Color(0xFFBBBBBB), fontSize: 9), - ), - ], - ), - ), - const SizedBox(width: 8), - SizedBox( - width: 60, - height: 28, - child: FilledButton( - onPressed: () => _resume(drama), - style: FilledButton.styleFrom( - backgroundColor: AppColors.blue, - foregroundColor: AppColors.onPrimary, - padding: EdgeInsets.zero, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(9), - ), - textStyle: const TextStyle( - fontSize: 10, - fontWeight: FontWeight.w700, - ), - ), - child: Text(l10n.resume), - ), - ), - ], - ), - ); - } - - Widget _library(AppLocalizations l10n) { - final tabs = [l10n.history, l10n.watchlist, l10n.likedTab]; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - for (var index = 0; index < tabs.length; index++) - Padding( - padding: EdgeInsets.only( - right: index == tabs.length - 1 ? 0 : 20, - ), - child: Semantics( - selected: logic.section == index, - button: true, - child: InkWell( - key: Key('mine-library-tab-$index'), - onTap: () => logic.selectSection(index), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 6), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - tabs[index], - maxLines: 1, - style: TextStyle( - color: logic.section == index - ? AppColors.blue - : const Color(0xFFE4DBDD), - fontSize: 14, - fontWeight: FontWeight.w800, - ), - ), - const SizedBox(height: 4), - AnimatedContainer( - duration: const Duration(milliseconds: 160), - width: 18, - height: 2, - decoration: BoxDecoration( - color: logic.section == index - ? AppColors.blue - : Colors.transparent, - borderRadius: BorderRadius.circular(1), - ), - ), - ], - ), - ), - ), - ), - ), - ], - ), - ), - const SizedBox(height: 12), - if (logic.section == 1 && context.watch().loading) - const Center( - child: Padding( - padding: EdgeInsets.all(12), - child: CircularProgressIndicator(strokeWidth: 2), - ), - ) - else if (logic.section == 1) - ..._favoriteCards(l10n) - else if (logic.section == 2) - ..._likedCards(l10n) - else if (logic.recentLoading) - const Center( - child: Padding( - padding: EdgeInsets.all(12), - child: CircularProgressIndicator(strokeWidth: 2), - ), - ) - else if (_recentDramas.isEmpty) - EmptyState( - key: const Key('mine-recent-empty'), - icon: Icons.history_rounded, - title: l10n.noStories, - subtitle: l10n.emptyHint, - compact: true, - ) - else - for (final drama in _recentDramas) ...[ - _historyCard(l10n, drama), - const SizedBox(height: 8), - ], - ], - ); - } - - List _favoriteCards(AppLocalizations l10n) { - final favorites = context.watch(); - if (favorites.error != null && favorites.cards.isEmpty) { - return [ - EmptyState( - icon: Icons.cloud_off_rounded, - title: errorText(l10n, favorites.error, l10n.favoritesError), - actionLabel: l10n.retry, - onAction: () => unawaited(favorites.load()), - compact: true, - ), - ]; - } - if (favorites.cards.isEmpty) { - return [ - EmptyState( - key: const Key('mine-saved-empty'), - icon: Icons.bookmark_border_rounded, - title: l10n.noSavedShows, - subtitle: l10n.emptyHint, - compact: true, - ), - ]; - } - return [ - for (final drama in favorites.cards) ...[ - _smallDramaCard(l10n, drama), - const SizedBox(height: 8), - ], - ]; - } - - List _likedCards(AppLocalizations l10n) { - final interactions = context.watch(); - final dramas = [ - ...?widget.dramas, - widget.drama, - ].where((drama) => interactions.isLiked(drama.id)).toList(); - if (dramas.isEmpty) { - return [ - EmptyState( - key: const Key('mine-saved-empty'), - icon: Icons.favorite_border_rounded, - title: l10n.noSavedShows, - subtitle: l10n.emptyHint, - compact: true, - ), - ]; - } - return [ - for (final drama in dramas) ...[ - _smallDramaCard(l10n, drama), - const SizedBox(height: 8), - ], - ]; - } - - Widget _historyCard(AppLocalizations l10n, Drama drama) { - final record = _recordFor(drama.id); - return InkWell( - key: Key('mine-history-card-${drama.id}'), - onTap: () => _resume(drama), - borderRadius: BorderRadius.circular(12), - child: Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: AppColors.card, - borderRadius: BorderRadius.circular(12), - ), - child: Row( - children: [ - _poster(drama, width: 48, height: 60, radius: 7), - const SizedBox(width: 9), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - localizedDramaTitle(l10n, drama), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.onSurface, - fontSize: 11, - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 3), - Text( - record == null - ? l10n.episodeCount(drama.episodeCount) - : '${_episodeText(drama, record)} · ' - '${_clock(record.position)} / ${_clock(record.duration)}', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: Color(0xFFBBBBBB), - fontSize: 9, - ), - ), - ], - ), - ), - Container( - width: 26, - height: 26, - decoration: const BoxDecoration( - color: Color(0xFF313137), - shape: BoxShape.circle, - ), - child: const Icon( - Icons.play_arrow_rounded, - color: AppColors.blue, - size: 15, - ), - ), - ], - ), - ), - ); - } - - Widget _smallDramaCard(AppLocalizations l10n, Drama drama) { - return InkWell( - onTap: () { - if (widget.onDramaSelected != null) { - widget.onDramaSelected!(drama); - } else { - _resume(drama); - } - }, - borderRadius: BorderRadius.circular(12), - child: Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: AppColors.card, - borderRadius: BorderRadius.circular(12), - ), - child: Row( - children: [ - _poster(drama, width: 48, height: 60, radius: 7), - const SizedBox(width: 9), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - localizedDramaTitle(l10n, drama), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.onSurface, - fontSize: 11, - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 3), - Text( - l10n.episodeCount(drama.episodeCount), - style: const TextStyle( - color: Color(0xFFBBBBBB), - fontSize: 9, - ), - ), - ], - ), - ), - const Icon( - Icons.chevron_right_rounded, - color: AppColors.muted, - size: 16, - ), - ], - ), - ), - ); - } - - Widget _languageRow(AppLocalizations l10n) { - final locale = context.watch(); - final code = locale.locale?.languageCode ?? 'id'; - final value = switch (code) { - 'zh' => l10n.simplifiedChinese, - 'en' => l10n.english, - _ => l10n.bahasaIndonesia, - }; - return InkWell( - key: const Key('mine-language'), - onTap: () => logic.showLanguage(context), - borderRadius: BorderRadius.circular(12), - child: Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: AppColors.card, - borderRadius: BorderRadius.circular(12), - ), - child: Row( - children: [ - const Icon( - Icons.language_rounded, - color: AppColors.muted, - size: 14, - ), - const SizedBox(width: 9), - Expanded( - child: Text( - l10n.appLanguage, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.onSurface, - fontSize: 11, - ), - ), - ), - Text( - value, - style: const TextStyle(color: AppColors.accent, fontSize: 10), - ), - const SizedBox(width: 3), - const Icon( - Icons.chevron_right_rounded, - color: AppColors.accent, - size: 16, - ), - ], - ), - ), - ); - } - - Widget _menuCard(AppLocalizations l10n) { - return Material( - color: AppColors.card, - borderRadius: BorderRadius.circular(12), - clipBehavior: Clip.antiAlias, - child: Column( - children: [ - _menuTile( - icon: Icons.notifications_none_rounded, - title: l10n.notices, - key: const Key('mine-notices'), - onTap: () => Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const NoticesPage()), - ), - ), - _menuTile( - icon: Icons.history_rounded, - title: l10n.watchHistory, - onTap: () => Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const WatchRecordsPage()), - ), - ), - _menuTile( - icon: Icons.favorite_outline_rounded, - title: l10n.myFavorites, - onTap: () => Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const FavoritesPage()), - ), - ), - _menuTile( - icon: Icons.download_outlined, - title: l10n.downloads, - onTap: () => Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const DownloadsPage()), - ), - ), - _menuTile( - key: const Key('mine-vip'), - icon: Icons.workspace_premium_outlined, - title: l10n.vipMembership, - onTap: _openVip, - ), - _menuTile( - icon: Icons.settings_rounded, - title: l10n.settings, - onTap: () => logic.showLanguage(context), - ), - ], - ), - ); - } - - Widget _menuTile({ - required IconData icon, - required String title, - required VoidCallback onTap, - Key? key, - }) { - return ListTile( - key: key, - dense: true, - minTileHeight: 44, - leading: Icon(icon, color: AppColors.secondary, size: 17), - title: Text( - title, - style: const TextStyle(color: AppColors.onSurface, fontSize: 11), - ), - trailing: const Icon( - Icons.chevron_right_rounded, - color: AppColors.muted, - size: 16, - ), - onTap: onTap, - ); - } - - Widget _bottomNav(AppLocalizations l10n) => AppBottomNav( - items: [ - AppBottomNavItem( - tabKey: const Key('mine-nav-home'), - icon: Icons.play_circle_outline_rounded, - selectedIcon: Icons.play_circle_fill_rounded, - label: l10n.homeTab, - onTap: () => logic.act( - context, - MyPageAction.home, - embedded: widget.embedded, - onAction: widget.onAction, - ), - ), - AppBottomNavItem( - tabKey: const Key('mine-nav-discover'), - icon: Icons.explore_outlined, - selectedIcon: Icons.explore_rounded, - label: l10n.discoverTab, - onTap: () => logic.act( - context, - MyPageAction.discover, - embedded: widget.embedded, - onAction: widget.onAction, - ), - ), - AppBottomNavItem( - tabKey: const Key('mine-nav-current'), - icon: Icons.video_library_outlined, - selectedIcon: Icons.video_library_rounded, - label: l10n.myTab, - selected: true, - onTap: () {}, - ), - ], - ); - - List get _recentDramas { - final pool = [...?widget.dramas, widget.drama]; - final result = []; - for (final item in logic.recent) { - for (final drama in pool) { - if (drama.id == item.dramaId && - !result.any((picked) => picked.id == drama.id)) { - result.add(drama); - } - } - } - return result; - } - - WatchProgress? _recordFor(String dramaId) { - for (final record in logic.recent) { - if (record.dramaId == dramaId) return record; - } - return null; - } - - String _episodeText(Drama drama, WatchProgress record) { - for (final episode in drama.episodes) { - if (episode.id == record.episodeId) { - return AppLocalizations.of( - context, - )!.episodeOfTotal(episode.number, drama.episodeCount); - } - } - return AppLocalizations.of(context)!.episodeCount(drama.episodeCount); - } - void _resume(Drama drama) { - if (widget.onDramaSelected != null) { - widget.onDramaSelected!(drama); - } else { - logic.act( - context, - MyPageAction.resume, - embedded: widget.embedded, - onAction: widget.onAction, - ); + final callback = widget.callbacks.onDramaSelected; + if (callback != null) { + callback(drama); + return; } - } - - Widget _poster( - Drama drama, { - required double width, - required double height, - required double radius, - }) => ClipRRect( - borderRadius: BorderRadius.circular(radius), - child: RemoteImage(drama.posterUrl, width: width, height: height), - ); - - String _clock(Duration duration) { - final minutes = duration.inMinutes.remainder(60).toString().padLeft(2, '0'); - final seconds = duration.inSeconds.remainder(60).toString().padLeft(2, '0'); - return '$minutes:$seconds'; - } -} - -class _RoundHeaderButton extends StatelessWidget { - const _RoundHeaderButton({ - super.key, - required this.tooltip, - required this.icon, - required this.onTap, - }); - - final String tooltip; - final IconData icon; - 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, + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => PlayerPage( + drama: drama, + initialIndex: 0, + wallet: context.read(), + downloads: context.read(), ), - icon: Icon(icon, size: 18), ), ); } + + void _openDestination(MineDestination destination) { + switch (destination) { + case MineDestination.watchlist: + _push(const FavoritesPage()); + case MineDestination.history: + _push(const WatchRecordsPage()); + case MineDestination.downloads: + _push(const DownloadsPage()); + case MineDestination.wallet: + _openProtected(const WalletPage()); + case MineDestination.vip: + _openVip(); + case MineDestination.settings: + _push(const SettingsPage()); + } + } + + void _push(Widget page) { + Navigator.of(context).push(MaterialPageRoute(builder: (_) => page)); + } + + Future _openProtected(Widget page) async { + if (!context.read().isSignedIn) { + await ensureSignedIn(context); + return; + } + if (mounted) _push(page); + } + + void _openVip() { + final wallet = context.read(); + _openProtected(VipPage(onPurchased: () => unawaited(wallet.load()))); + } + + Widget _bottomNav(AppLocalizations l10n) { + return AppBottomNav( + items: [ + AppBottomNavItem( + tabKey: const Key('mine-nav-home'), + icon: Icons.play_circle_outline_rounded, + selectedIcon: Icons.play_circle_fill_rounded, + label: l10n.homeTab, + onTap: () => _act(MyPageAction.home), + ), + AppBottomNavItem( + tabKey: const Key('mine-nav-discover'), + icon: Icons.explore_outlined, + selectedIcon: Icons.explore_rounded, + label: l10n.discoverTab, + onTap: () => _act(MyPageAction.discover), + ), + AppBottomNavItem( + tabKey: const Key('mine-nav-current'), + icon: Icons.video_library_outlined, + selectedIcon: Icons.video_library_rounded, + label: l10n.myTab, + selected: true, + onTap: () {}, + ), + ], + ); + } + + void _act(MyPageAction action) { + logic.act( + context, + action, + embedded: embedded, + onAction: widget.callbacks.onAction, + ); + } } diff --git a/lib/pages/mine/widgets/continue_watching_card.dart b/lib/pages/mine/widgets/continue_watching_card.dart new file mode 100644 index 0000000..357f595 --- /dev/null +++ b/lib/pages/mine/widgets/continue_watching_card.dart @@ -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'; +} diff --git a/lib/pages/mine/widgets/mine_header.dart b/lib/pages/mine/widgets/mine_header.dart new file mode 100644 index 0000000..f2559c2 --- /dev/null +++ b/lib/pages/mine/widgets/mine_header.dart @@ -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(); + 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), + ), + ], + ); + } +} diff --git a/lib/pages/mine/widgets/mine_menu.dart b/lib/pages/mine/widgets/mine_menu.dart new file mode 100644 index 0000000..125e8ad --- /dev/null +++ b/lib/pages/mine/widgets/mine_menu.dart @@ -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 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 _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 _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, + ), + ); + } +} diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index f7f8ed9..5d47ab1 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -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 showMy(BuildContext context, {int initialSection = 0}) async { + Future 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( - MaterialPageRoute( - builder: (_) => MyPage( - drama: drama, - duration: duration, - initialSection: initialSection, - ), - ), - ); + final action = await Navigator.of( + context, + ).push(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( - 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); - } } } diff --git a/lib/pages/ranking/ranking_view.dart b/lib/pages/ranking/ranking_view.dart deleted file mode 100644 index 25fcf0d..0000000 --- a/lib/pages/ranking/ranking_view.dart +++ /dev/null @@ -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 sections; - final List fallback; - final ValueChanged onOpen; - final bool embedded; - final bool active; - - @override - State createState() => _RankingPageState(); -} - -class _RankingPageState extends State { - 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'; - } -} diff --git a/lib/pages/settings/settings_page.dart b/lib/pages/settings/settings_page.dart new file mode 100644 index 0000000..b1f1ec8 --- /dev/null +++ b/lib/pages/settings/settings_page.dart @@ -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(); + 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(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), + ); + } +} diff --git a/test/account/mine_account_entry_test.dart b/test/account/mine_account_entry_test.dart index d0612ca..8cfad42 100644 --- a/test/account/mine_account_entry_test.dart +++ b/test/account/mine_account_entry_test.dart @@ -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); diff --git a/test/category/category_controller_test.dart b/test/category/category_controller_test.dart index 6ade3eb..b67a7eb 100644 --- a/test/category/category_controller_test.dart +++ b/test/category/category_controller_test.dart @@ -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 { diff --git a/test/config/app_locale_test.dart b/test/config/app_locale_test.dart new file mode 100644 index 0000000..a73bfb2 --- /dev/null +++ b/test/config/app_locale_test.dart @@ -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')); + }); +} diff --git a/test/config/locale_controller_test.dart b/test/config/locale_controller_test.dart deleted file mode 100644 index 925dc07..0000000 --- a/test/config/locale_controller_test.dart +++ /dev/null @@ -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 getString(String key, SharedPreferencesOptions options) => - Future.error(StateError('preferences unavailable')); - - @override - Future setString( - String key, - String value, - SharedPreferencesOptions options, - ) => Future.error(StateError('preferences unavailable')); -} diff --git a/test/discover/discover_logic_test.dart b/test/discover/discover_logic_test.dart index 08c02fa..bd84546 100644 --- a/test/discover/discover_logic_test.dart +++ b/test/discover/discover_logic_test.dart @@ -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 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 = []; final popped = Completer(); 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++); diff --git a/test/discover/discover_search_test.dart b/test/discover/discover_search_test.dart index 0ddd7e1..cf8cfae 100644 --- a/test/discover/discover_search_test.dart +++ b/test/discover/discover_search_test.dart @@ -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(); }); } diff --git a/test/discover/home_all_dramas_grid_test.dart b/test/discover/home_all_dramas_grid_test.dart index e515fc5..8b5c736 100644 --- a/test/discover/home_all_dramas_grid_test.dart +++ b/test/discover/home_all_dramas_grid_test.dart @@ -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( - find.descendant( - of: find.byKey(key), - matching: find.byType(Icon), - ), - ) - .size, - 18, - ); - } - expect(find.text('For You'), findsNothing); - final masonry = tester.widget( - find.byKey(const Key('home-all-dramas-grid')), - ); - expect(masonry.children, hasLength(3)); - expect((masonry.children[1] as SizedBox).width, 10); - expect( - tester - .widget(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( - 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( - find.byKey(const Key('home-drama-title-city-lights-demo')), - ) - .maxLines, - 1, - ); - expect( - tester - .widget( - 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( + 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(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( - 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 _pumpApp( +Future _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 _pumpApp( catalogRepository: catalog, searchRepository: catalog, categoryRepository: categoryRepository, - watchHistoryRepository: history ?? InMemoryWatchHistoryRepository(), + watchHistoryRepository: InMemoryWatchHistoryRepository(), favoriteRepository: InMemoryFavoriteRepository(), playbackRepository: FakePlaybackRepository(), commentRepository: FakeCommentRepository(), @@ -439,6 +172,8 @@ Future _pumpApp( ), ); await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('tab-discover'))); + await tester.pumpAndSettle(); } class _GridCategoryRepository implements CategoryRepository { diff --git a/test/home/home_logic_test.dart b/test/home/home_logic_test.dart index 85c609c..54ff50f 100644 --- a/test/home/home_logic_test.dart +++ b/test/home/home_logic_test.dart @@ -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(); - }); } diff --git a/test/home/home_nav_ui_test.dart b/test/home/home_nav_ui_test.dart index 9ae1b1c..65db607 100644 --- a/test/home/home_nav_ui_test.dart +++ b/test/home/home_nav_ui_test.dart @@ -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); }); } diff --git a/test/mine/mine_logic_test.dart b/test/mine/mine_logic_test.dart index 5aed167..30cb8d5 100644 --- a/test/mine/mine_logic_test.dart +++ b/test/mine/mine_logic_test.dart @@ -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 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 = []; final popped = Completer(); 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.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++); diff --git a/test/mine/mine_preview_layout_test.dart b/test/mine/mine_preview_layout_test.dart index d9c9946..9edf04e 100644 --- a/test/mine/mine_preview_layout_test.dart +++ b/test/mine/mine_preview_layout_test.dart @@ -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( - 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( - find.descendant( - of: tab0, - matching: find.byType(AnimatedContainer), - ), - ) - .decoration - as BoxDecoration) - .color, - AppColors.blue, - ); - expect( - (tester - .widget( - 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); }); } diff --git a/test/mine/server_endpoint_setting_test.dart b/test/mine/server_endpoint_setting_test.dart deleted file mode 100644 index 2d468fb..0000000 --- a/test/mine/server_endpoint_setting_test.dart +++ /dev/null @@ -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 _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(); -} diff --git a/test/discover/discover_language_filter_test.dart b/test/mine/settings_page_test.dart similarity index 52% rename from test/discover/discover_language_filter_test.dart rename to test/mine/settings_page_test.dart index 003d3db..0a8e1a8 100644 --- a/test/discover/discover_language_filter_test.dart +++ b/test/mine/settings_page_test.dart @@ -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> fetchCategories() async => const []; - - @override - Future 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, -); diff --git a/test/notice/notices_entry_test.dart b/test/notice/notices_entry_test.dart index 1767c7f..221d024 100644 --- a/test/notice/notices_entry_test.dart +++ b/test/notice/notices_entry_test.dart @@ -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); diff --git a/test/ranking/ranking_logic_test.dart b/test/ranking/ranking_logic_test.dart index 8d1f935..bc6897e 100644 --- a/test/ranking/ranking_logic_test.dart +++ b/test/ranking/ranking_logic_test.dart @@ -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', () { diff --git a/test/ranking/ranking_view_test.dart b/test/ranking/ranking_view_test.dart deleted file mode 100644 index 7c226a8..0000000 --- a/test/ranking/ranking_view_test.dart +++ /dev/null @@ -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 _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(); -} diff --git a/test/widget_test.dart b/test/widget_test.dart index 430b533..1fce792 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -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);