diff --git a/lib/core/theme/app_dimens.dart b/lib/core/theme/app_dimens.dart index 32bfcda..c482578 100644 --- a/lib/core/theme/app_dimens.dart +++ b/lib/core/theme/app_dimens.dart @@ -43,7 +43,6 @@ abstract final class AppDimens { static const double buttonLarge = 44; static const double tapTarget = 44; static const double contentHeaderHeight = 46; - static const double filterChipHeight = 34; static const double navHeight = 54; static const double navIcon = 17; @@ -61,11 +60,7 @@ abstract final class AppDimens { static const double watchPlaceholderHeight = 114; static const double heroBannerHeight = 176; - static const double continueCoverWidth = 52; - static const double continueCoverHeight = 66; static const double cardMeta = 9; static const double cardViews = 9; - static const double createIcon = 22; - static const double createArrow = 18; } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 9bc68f1..30c0b29 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -14,7 +14,7 @@ "openQuickMenu": "Open quick menu", "quickAccess": "Quick access", "currentSeries": "Current series", - "discoverTab": "Discover", + "discoverTab": "Theater", "searchDiscover": "Search dramas, actors, genres...", "featured": "Featured", "recommended": "Recommended", @@ -104,13 +104,7 @@ "recommendedTab": "Recommended", "noCategoryDramas": "No dramas in this category yet.", "allDramas": "All Dramas", - "allLanguages": "All languages", - "bahasaMelayu": "Malay", "createSubtitle": "Stories become short dramas", - "createAiTitle": "AI production", - "createAiSubtitle": "Start from a story or an idea", - "createUploadTitle": "Upload and publish", - "createUploadSubtitle": "Publish a finished short drama", "weekdayMon": "Mon", "weekdayTue": "Tue", "weekdayWed": "Wed", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 63617df..6ec3a23 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -14,7 +14,7 @@ "openQuickMenu": "Buka menu cepat", "quickAccess": "Akses cepat", "currentSeries": "Serial saat ini", - "discoverTab": "Temukan", + "discoverTab": "Teater", "searchDiscover": "Cari drama, aktor, genre...", "featured": "Unggulan", "recommended": "Rekomendasi", @@ -104,13 +104,7 @@ "recommendedTab": "Rekomendasi", "noCategoryDramas": "Belum ada drama di kategori ini.", "allDramas": "Semua Drama", - "allLanguages": "Semua bahasa", - "bahasaMelayu": "Bahasa Melayu", "createSubtitle": "Cerita menjadi drama pendek", - "createAiTitle": "Produksi AI", - "createAiSubtitle": "Mulai dari cerita atau ide", - "createUploadTitle": "Unggah dan terbitkan", - "createUploadSubtitle": "Terbitkan drama pendek yang sudah selesai", "weekdayMon": "Sen", "weekdayTue": "Sel", "weekdayWed": "Rab", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index c184243..44dbabc 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -187,7 +187,7 @@ abstract class AppLocalizations { /// No description provided for @discoverTab. /// /// In en, this message translates to: - /// **'Discover'** + /// **'Theater'** String get discoverTab; /// No description provided for @searchDiscover. @@ -550,48 +550,12 @@ abstract class AppLocalizations { /// **'All Dramas'** String get allDramas; - /// No description provided for @allLanguages. - /// - /// In en, this message translates to: - /// **'All languages'** - String get allLanguages; - - /// No description provided for @bahasaMelayu. - /// - /// In en, this message translates to: - /// **'Malay'** - String get bahasaMelayu; - /// No description provided for @createSubtitle. /// /// In en, this message translates to: /// **'Stories become short dramas'** String get createSubtitle; - /// No description provided for @createAiTitle. - /// - /// In en, this message translates to: - /// **'AI production'** - String get createAiTitle; - - /// No description provided for @createAiSubtitle. - /// - /// In en, this message translates to: - /// **'Start from a story or an idea'** - String get createAiSubtitle; - - /// No description provided for @createUploadTitle. - /// - /// In en, this message translates to: - /// **'Upload and publish'** - String get createUploadTitle; - - /// No description provided for @createUploadSubtitle. - /// - /// In en, this message translates to: - /// **'Publish a finished short drama'** - String get createUploadSubtitle; - /// No description provided for @weekdayMon. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 10367de..0195af7 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -52,7 +52,7 @@ class AppLocalizationsEn extends AppLocalizations { String get currentSeries => 'Current series'; @override - String get discoverTab => 'Discover'; + String get discoverTab => 'Theater'; @override String get searchDiscover => 'Search dramas, actors, genres...'; @@ -248,27 +248,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get allDramas => 'All Dramas'; - @override - String get allLanguages => 'All languages'; - - @override - String get bahasaMelayu => 'Malay'; - @override String get createSubtitle => 'Stories become short dramas'; - @override - String get createAiTitle => 'AI production'; - - @override - String get createAiSubtitle => 'Start from a story or an idea'; - - @override - String get createUploadTitle => 'Upload and publish'; - - @override - String get createUploadSubtitle => 'Publish a finished short drama'; - @override String get weekdayMon => 'Mon'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 69ba775..2a36cc2 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -52,7 +52,7 @@ class AppLocalizationsId extends AppLocalizations { String get currentSeries => 'Serial saat ini'; @override - String get discoverTab => 'Temukan'; + String get discoverTab => 'Teater'; @override String get searchDiscover => 'Cari drama, aktor, genre...'; @@ -248,28 +248,9 @@ class AppLocalizationsId extends AppLocalizations { @override String get allDramas => 'Semua Drama'; - @override - String get allLanguages => 'Semua bahasa'; - - @override - String get bahasaMelayu => 'Bahasa Melayu'; - @override String get createSubtitle => 'Cerita menjadi drama pendek'; - @override - String get createAiTitle => 'Produksi AI'; - - @override - String get createAiSubtitle => 'Mulai dari cerita atau ide'; - - @override - String get createUploadTitle => 'Unggah dan terbitkan'; - - @override - String get createUploadSubtitle => - 'Terbitkan drama pendek yang sudah selesai'; - @override String get weekdayMon => 'Sen'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 2256dbc..1cf3021 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -51,7 +51,7 @@ class AppLocalizationsZh extends AppLocalizations { String get currentSeries => '当前短剧'; @override - String get discoverTab => '发现'; + String get discoverTab => '剧场'; @override String get searchDiscover => '搜索短剧、演员、类型…'; @@ -243,27 +243,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get allDramas => '全部剧目'; - @override - String get allLanguages => '全部语言'; - - @override - String get bahasaMelayu => '马来语'; - @override String get createSubtitle => '让故事成为漫剧'; - @override - String get createAiTitle => 'AI 制作'; - - @override - String get createAiSubtitle => '从故事或想法开始'; - - @override - String get createUploadTitle => '上传发布'; - - @override - String get createUploadSubtitle => '发布已完成的漫剧'; - @override String get weekdayMon => '周一'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index a434316..8bbee93 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -14,7 +14,7 @@ "openQuickMenu": "打开快捷菜单", "quickAccess": "快捷入口", "currentSeries": "当前短剧", - "discoverTab": "发现", + "discoverTab": "剧场", "searchDiscover": "搜索短剧、演员、类型…", "featured": "精选", "recommended": "推荐", @@ -104,13 +104,7 @@ "recommendedTab": "推荐", "noCategoryDramas": "这个分类暂时没有剧集。", "allDramas": "全部剧目", - "allLanguages": "全部语言", - "bahasaMelayu": "马来语", "createSubtitle": "让故事成为漫剧", - "createAiTitle": "AI 制作", - "createAiSubtitle": "从故事或想法开始", - "createUploadTitle": "上传发布", - "createUploadSubtitle": "发布已完成的漫剧", "weekdayMon": "周一", "weekdayTue": "周二", "weekdayWed": "周三", diff --git a/lib/pages/create/create_view.dart b/lib/pages/create/create_view.dart index 0ff779c..4e0a414 100644 --- a/lib/pages/create/create_view.dart +++ b/lib/pages/create/create_view.dart @@ -1,6 +1,5 @@ // Author: fruit.com.cn -// K28 creation tab placeholder; creator workflows land in the next batch. import 'package:flutter/material.dart'; import '../../core/theme/app_colors.dart'; @@ -8,7 +7,7 @@ import '../../core/theme/app_dimens.dart'; import '../../core/theme/app_theme.dart'; import '../../l10n/app_localizations.dart'; -/// Honest skeleton for the future creation workspace. +/// K28-A1 placeholder for the future creation workspace. class CreatePage extends StatelessWidget { const CreatePage({super.key}); @@ -19,7 +18,7 @@ class CreatePage extends StatelessWidget { data: AppTheme.dark(), child: SafeArea( bottom: false, - child: ListView( + child: Padding( key: const Key('create-page'), padding: const EdgeInsets.fromLTRB( AppDimens.pageHorizontal, @@ -27,119 +26,30 @@ class CreatePage extends StatelessWidget { AppDimens.pageHorizontal, AppDimens.spaceXl, ), - children: [ - Text( - l10n.createTab, - style: const TextStyle( - fontFamily: 'Outfit', - color: AppColors.onSurface, - fontSize: AppDimens.pageTitle, - fontWeight: FontWeight.w800, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.createTab, + style: const TextStyle( + fontFamily: 'Outfit', + color: AppColors.onSurface, + fontSize: AppDimens.pageTitle, + fontWeight: FontWeight.w800, + ), ), - ), - const SizedBox(height: AppDimens.spaceSm), - Text( - l10n.createSubtitle, - style: const TextStyle( - color: AppColors.muted, - fontSize: AppDimens.body, + const SizedBox(height: AppDimens.spaceSm), + Text( + l10n.createSubtitle, + style: const TextStyle( + color: AppColors.muted, + fontSize: AppDimens.body, + ), ), - ), - const SizedBox(height: AppDimens.spaceXl), - _CreateEntry( - key: const Key('create-ai'), - icon: Icons.auto_awesome_rounded, - title: l10n.createAiTitle, - subtitle: l10n.createAiSubtitle, - detail: l10n.comingSoon, - ), - const SizedBox(height: AppDimens.spaceMd), - _CreateEntry( - key: const Key('create-upload'), - icon: Icons.upload_file_rounded, - title: l10n.createUploadTitle, - subtitle: l10n.createUploadSubtitle, - detail: l10n.comingSoon, - ), - ], + ], + ), ), ), ); } } - -class _CreateEntry extends StatelessWidget { - const _CreateEntry({ - super.key, - required this.icon, - required this.title, - required this.subtitle, - required this.detail, - }); - - final IconData icon; - final String title; - final String subtitle; - final String detail; - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.all(AppDimens.cardPadding), - decoration: BoxDecoration( - color: AppColors.card, - borderRadius: BorderRadius.circular(AppDimens.cardRadius), - border: Border.all(color: AppColors.border), - ), - child: Row( - children: [ - Container( - width: AppDimens.tapTarget, - height: AppDimens.tapTarget, - decoration: BoxDecoration( - color: AppColors.blue.withValues(alpha: 0.14), - borderRadius: BorderRadius.circular(AppDimens.innerRadius), - ), - child: Icon( - icon, - color: AppColors.blue, - size: AppDimens.createIcon, - ), - ), - const SizedBox(width: AppDimens.spaceMd), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: const TextStyle( - color: AppColors.onSurface, - fontSize: AppDimens.listTitle, - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: AppDimens.spaceXs), - Text( - '$subtitle · $detail', - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.muted, - fontSize: AppDimens.caption, - ), - ), - ], - ), - ), - const SizedBox(width: AppDimens.spaceSm), - const Icon( - Icons.chevron_right_rounded, - color: AppColors.subtle, - size: AppDimens.createArrow, - ), - ], - ), - ); - } -} diff --git a/lib/pages/discover/discover_logic.dart b/lib/pages/discover/discover_logic.dart index b654e35..7df910f 100644 --- a/lib/pages/discover/discover_logic.dart +++ b/lib/pages/discover/discover_logic.dart @@ -28,8 +28,6 @@ class DiscoverLogic extends ChangeNotifier { String _lastRequestedQuery = ''; int _searchRequest = 0; bool _disposed = false; - TheaterLanguage language = TheaterLanguage.all; - String? selectedCategoryId; bool get hasSearchQuery => search.text.trim().isNotEmpty; @@ -50,20 +48,6 @@ class DiscoverLogic extends ChangeNotifier { void openSearch() => searchFocus.requestFocus(); - /// Selects a language for the visual filter state. - void selectLanguage(TheaterLanguage value) { - if (language == value) return; - language = value; - notify(); - } - - /// Selects one category chip; null represents all categories. - void selectCategory(String? categoryId) { - if (selectedCategoryId == categoryId) return; - selectedCategoryId = categoryId; - notify(); - } - void retrySearch() { final query = search.text.trim(); if (query.isNotEmpty) unawaited(_runSearch(query)); diff --git a/lib/pages/discover/discover_types.dart b/lib/pages/discover/discover_types.dart index 25e3f47..d369cc9 100644 --- a/lib/pages/discover/discover_types.dart +++ b/lib/pages/discover/discover_types.dart @@ -7,9 +7,6 @@ import '../../core/data/models/drama.dart'; /// 剧场页与根导航之间的动作。 enum DiscoverPageAction { home, create, my } -/// Language choices shown by the theater filter UI. -enum TheaterLanguage { all, zh, id, ms, en } - /// 剧场页导航回调;没有 `onAction` 时按独立页面处理。 class DiscoverPageCallbacks { const DiscoverPageCallbacks({ diff --git a/lib/pages/discover/discover_view.dart b/lib/pages/discover/discover_view.dart index 3f7a245..277177c 100644 --- a/lib/pages/discover/discover_view.dart +++ b/lib/pages/discover/discover_view.dart @@ -4,8 +4,12 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; +import '../../core/data/models/content_category.dart'; import '../../core/data/models/drama.dart'; +import '../../core/data/models/home_feed.dart'; +import '../../core/data/repositories/catalog_repository.dart'; import '../../core/data/repositories/search_repository.dart'; +import '../../core/data/services/catalog_controller.dart'; import '../../core/data/services/category_controller.dart'; import '../../core/formatting/error_text.dart'; import '../../core/theme/app_colors.dart'; @@ -15,14 +19,19 @@ import '../../core/widgets/app_bottom_nav.dart'; import '../../core/widgets/empty_state.dart'; import '../../l10n/app_localizations.dart'; import '../drama_detail/drama_detail_view.dart'; +import 'category_dramas_page.dart'; import 'discover_binding.dart'; import 'discover_logic.dart'; import 'discover_types.dart'; +import 'widgets/category_grid.dart'; import 'widgets/discover_header.dart'; +import 'widgets/discover_search_field.dart'; import 'widgets/discover_search_results.dart'; import 'widgets/drama_grid.dart'; +import 'widgets/feed_banner_carousel.dart'; +import 'widgets/ranking_section.dart'; -/// Theater page with fixed search, category chips, and a two-column catalogue. +/// 剧场页:搜索 + 分类九宫格 + 榜单 + 全部剧目。 class DiscoverPage extends StatefulWidget { const DiscoverPage({ super.key, @@ -87,18 +96,13 @@ class _DiscoverPageState extends State { Widget _page(BuildContext context) { final l10n = AppLocalizations.of(context)!; final categories = context.watch(); + final banners = context.watch().banners; final content = SafeArea( bottom: !embedded, child: Column( children: [ - DiscoverHeader( - controller: logic.search, - focusNode: logic.searchFocus, - onChanged: (_) => logic.onSearchChanged(), - loading: logic.searchLoading, - onProfile: _openProfile, - ), - Expanded(child: _feed(l10n, categories)), + DiscoverHeader(onSearch: logic.openSearch, onProfile: _openProfile), + Expanded(child: _feed(l10n, categories, banners)), if (!embedded) _bottomNav(l10n), ], ), @@ -114,28 +118,43 @@ class _DiscoverPageState extends State { ); } - Widget _feed(AppLocalizations l10n, CategoryController categories) { - return GestureDetector( - behavior: HitTestBehavior.translucent, - onTap: logic.searchFocus.unfocus, - child: RefreshIndicator( - key: const Key('discover-refresh'), - color: AppColors.blue, - backgroundColor: AppColors.card, - onRefresh: categories.load, - child: ListView( - controller: logic.scroll, - physics: const AlwaysScrollableScrollPhysics(), - padding: const EdgeInsets.only(bottom: 24), - children: _content(l10n, categories), - ), + Widget _feed( + AppLocalizations l10n, + CategoryController categories, + 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, + ), + ), + ..._content(l10n, categories, banners), + ], ), ); } - List _content(AppLocalizations l10n, CategoryController categories) { + List _content( + AppLocalizations l10n, + CategoryController categories, + List banners, + ) { if (logic.hasSearchQuery) return [_searchContent(l10n)]; - return _theaterContent(l10n, categories); + return _theaterContent(l10n, categories, banners); } Widget _searchContent(AppLocalizations l10n) => DiscoverSearchResults( @@ -149,6 +168,7 @@ class _DiscoverPageState extends State { List _theaterContent( AppLocalizations l10n, CategoryController categories, + List banners, ) { if (categories.loading && categories.feed.sections.isEmpty) { return [_block(const CircularProgressIndicator(strokeWidth: 2))]; @@ -168,19 +188,38 @@ class _DiscoverPageState extends State { ), ]; } - return _theaterBlocks(l10n, categories); + return _theaterBlocks(l10n, categories, banners); } List _theaterBlocks( AppLocalizations l10n, CategoryController categories, + List banners, ) { - final dramas = logic.selectedCategoryId == null - ? categories.feed.dramas - : categories.dramasOf(logic.selectedCategoryId!); + final dramas = categories.feed.dramas; return [ - _categoryChips(l10n, categories), - _languageChips(l10n), + Padding( + padding: const EdgeInsets.only(top: 12), + child: CategoryGrid( + categories: categories.categories, + onTap: (category) => _openCategory(category, categories), + ), + ), + if (banners.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 12), + child: FeedBannerCarousel( + banners: banners, + active: widget.active, + onOpen: _openBanner, + ), + ), + if (categories.feed.sections.isNotEmpty || dramas.isNotEmpty) + RankingSection( + sections: categories.feed.sections, + fallback: dramas, + onOpen: _openDrama, + ), _sectionTitle(l10n.allDramas), if (dramas.isEmpty) _emptyCatalog(l10n) else _dramaGrid(l10n, dramas), ]; @@ -203,93 +242,6 @@ class _DiscoverPageState extends State { return DramaGrid(l10n: l10n, dramas: dramas, onOpen: _openDrama); } - Widget _categoryChips(AppLocalizations l10n, CategoryController categories) { - return Padding( - padding: const EdgeInsets.fromLTRB( - AppDimens.pageHorizontal, - AppDimens.spaceMd, - AppDimens.pageHorizontal, - 0, - ), - child: Wrap( - key: const Key('theater-category-chips'), - spacing: AppDimens.spaceSm, - runSpacing: AppDimens.spaceSm, - children: [ - _filterChip( - key: const Key('theater-category-all'), - label: l10n.all, - selected: logic.selectedCategoryId == null, - onSelected: () => logic.selectCategory(null), - ), - for (final category in categories.categories) - _filterChip( - key: Key('theater-category-${category.id}'), - label: category.name, - selected: logic.selectedCategoryId == category.id, - onSelected: () => logic.selectCategory(category.id), - ), - ], - ), - ); - } - - Widget _languageChips(AppLocalizations l10n) { - final entries = [ - (TheaterLanguage.all, l10n.allLanguages), - (TheaterLanguage.zh, l10n.simplifiedChinese), - (TheaterLanguage.id, l10n.bahasaIndonesia), - (TheaterLanguage.ms, l10n.bahasaMelayu), - (TheaterLanguage.en, l10n.english), - ]; - return Padding( - padding: const EdgeInsets.fromLTRB( - AppDimens.pageHorizontal, - AppDimens.spaceSm, - AppDimens.pageHorizontal, - 0, - ), - child: Wrap( - key: const Key('theater-language-chips'), - spacing: AppDimens.spaceSm, - runSpacing: AppDimens.spaceSm, - children: [ - for (final entry in entries) - _filterChip( - key: Key('theater-language-${entry.$1.name}'), - label: entry.$2, - selected: logic.language == entry.$1, - onSelected: () => logic.selectLanguage(entry.$1), - ), - ], - ), - ); - } - - Widget _filterChip({ - required Key key, - required String label, - required bool selected, - required VoidCallback onSelected, - }) { - return ChoiceChip( - key: key, - label: Text(label), - selected: selected, - showCheckmark: false, - onSelected: (_) => onSelected(), - backgroundColor: AppColors.card, - selectedColor: AppColors.blue, - side: BorderSide(color: selected ? AppColors.blue : AppColors.border), - labelStyle: TextStyle( - color: selected ? AppColors.onPrimary : AppColors.muted, - fontSize: AppDimens.chipFont, - fontWeight: FontWeight.w700, - ), - shape: const StadiumBorder(), - ); - } - Widget _sectionTitle(String title) { return Padding( padding: const EdgeInsets.fromLTRB(14, 18, 14, 10), @@ -339,6 +291,18 @@ class _DiscoverPageState extends State { ); } + 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) { @@ -354,6 +318,27 @@ class _DiscoverPageState extends State { ); } + 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; + } + } + 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( items: [ AppBottomNavItem( @@ -367,7 +352,7 @@ class _DiscoverPageState extends State { tabKey: const Key('discover-nav-current'), icon: Icons.explore_outlined, selectedIcon: Icons.explore_rounded, - label: l10n.theaterTab, + label: l10n.discoverTab, selected: true, onTap: () {}, ), diff --git a/lib/pages/discover/widgets/discover_header.dart b/lib/pages/discover/widgets/discover_header.dart index f034c89..1d20061 100644 --- a/lib/pages/discover/widgets/discover_header.dart +++ b/lib/pages/discover/widgets/discover_header.dart @@ -3,48 +3,37 @@ import 'package:flutter/material.dart'; import '../../../core/theme/app_colors.dart'; -import '../../../core/theme/app_dimens.dart'; import '../../../l10n/app_localizations.dart'; -import 'discover_search_field.dart'; -/// Theater header with a persistent search field and profile entry. +/// 剧场页页头:品牌、搜索与个人页入口。 class DiscoverHeader extends StatelessWidget { const DiscoverHeader({ super.key, - required this.controller, - required this.focusNode, - required this.onChanged, - required this.loading, + required this.onSearch, required this.onProfile, }); - final TextEditingController controller; - final FocusNode focusNode; - final ValueChanged onChanged; - final bool loading; + final VoidCallback onSearch; final VoidCallback onProfile; @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; return SizedBox( - height: AppDimens.contentHeaderHeight, + height: 46, child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: AppDimens.pageHorizontal, - ), + padding: const EdgeInsets.symmetric(horizontal: 14), child: Row( children: [ - Expanded( - child: DiscoverSearchField( - controller: controller, - focusNode: focusNode, - onChanged: onChanged, - loading: loading, - horizontalPadding: 0, - ), + _brand(l10n), + const Spacer(), + _HeaderIcon( + key: const Key('discover-search-button'), + icon: Icons.search_rounded, + tooltip: l10n.search, + onTap: onSearch, ), - const SizedBox(width: AppDimens.spaceSm), + const SizedBox(width: 8), _profile(l10n), ], ), @@ -52,6 +41,37 @@ class DiscoverHeader extends StatelessWidget { ); } + Widget _brand(AppLocalizations l10n) { + return Row( + children: [ + Container( + width: 24, + height: 24, + alignment: Alignment.center, + decoration: BoxDecoration( + color: AppColors.blue, + borderRadius: BorderRadius.circular(7), + ), + child: const Icon( + Icons.play_arrow_rounded, + color: AppColors.onPrimary, + size: 16, + ), + ), + const SizedBox(width: 8), + Text( + l10n.discoverTab, + style: const TextStyle( + fontFamily: 'Outfit', + color: AppColors.onSurface, + fontSize: 15, + fontWeight: FontWeight.w800, + ), + ), + ], + ); + } + Widget _profile(AppLocalizations l10n) { return Semantics( button: true, @@ -81,3 +101,34 @@ class DiscoverHeader extends StatelessWidget { ); } } + +class _HeaderIcon extends StatelessWidget { + const _HeaderIcon({ + super.key, + required this.icon, + required this.tooltip, + required this.onTap, + }); + + final IconData icon; + final String tooltip; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 36, + height: 36, + child: IconButton( + tooltip: tooltip, + onPressed: onTap, + padding: EdgeInsets.zero, + style: IconButton.styleFrom( + backgroundColor: const Color(0xFF2C2D33), + foregroundColor: AppColors.onSurface, + ), + icon: Icon(icon, size: 18), + ), + ); + } +} diff --git a/lib/pages/discover/widgets/discover_search_field.dart b/lib/pages/discover/widgets/discover_search_field.dart index 7688b8a..de549af 100644 --- a/lib/pages/discover/widgets/discover_search_field.dart +++ b/lib/pages/discover/widgets/discover_search_field.dart @@ -13,19 +13,17 @@ class DiscoverSearchField extends StatelessWidget { required this.focusNode, required this.onChanged, required this.loading, - this.horizontalPadding = 14, }); final TextEditingController controller; final FocusNode focusNode; final ValueChanged onChanged; final bool loading; - final double horizontalPadding; @override Widget build(BuildContext context) { return Padding( - padding: EdgeInsets.symmetric(horizontal: horizontalPadding), + padding: const EdgeInsets.symmetric(horizontal: 14), child: SizedBox( height: 36, child: TextField( diff --git a/lib/pages/discover/widgets/drama_grid.dart b/lib/pages/discover/widgets/drama_grid.dart index 71a3b55..7974b5d 100644 --- a/lib/pages/discover/widgets/drama_grid.dart +++ b/lib/pages/discover/widgets/drama_grid.dart @@ -2,10 +2,9 @@ import 'package:flutter/material.dart'; +import '../../../core/config/app_config.dart'; import '../../../core/data/models/drama.dart'; import '../../../core/theme/app_colors.dart'; -import '../../../core/theme/app_dimens.dart'; -import '../../../core/widgets/remote_image.dart'; import '../../../l10n/app_localizations.dart'; const int _columns = 2; @@ -84,8 +83,6 @@ class DramaGridCard extends StatelessWidget { _title(), const SizedBox(height: 2), _genre(), - const SizedBox(height: AppDimens.spaceXs), - _metadata(), ], ), ); @@ -118,7 +115,7 @@ class DramaGridCard extends StatelessWidget { l10n.free, style: const TextStyle( color: AppColors.onPrimary, - fontSize: AppDimens.cardMeta, + fontSize: 8, fontWeight: FontWeight.w800, ), ), @@ -133,7 +130,7 @@ class DramaGridCard extends StatelessWidget { overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.onSurface, - fontSize: AppDimens.previewSectionTitle, + fontSize: 11, fontWeight: FontWeight.w800, ), ); @@ -146,39 +143,7 @@ class DramaGridCard extends StatelessWidget { key: Key('home-drama-genre-${drama.id}'), maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.muted, - fontSize: AppDimens.cardMeta, - ), - ); - } - - Widget _metadata() { - return Wrap( - spacing: AppDimens.spaceSm, - runSpacing: AppDimens.spaceXs, - children: [ - Text( - l10n.episodeCount(drama.episodeCount), - key: Key('home-drama-episode-count-${drama.id}'), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.subtle, - fontSize: AppDimens.cardViews, - ), - ), - Text( - l10n.viewCountLabel(drama.viewCount), - key: Key('home-drama-views-${drama.id}'), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.subtle, - fontSize: AppDimens.cardViews, - ), - ), - ], + style: const TextStyle(color: Color(0xFFBBBBBB), fontSize: 9), ); } } @@ -190,12 +155,33 @@ class _DiscoverCoverImage extends StatelessWidget { @override Widget build(BuildContext context) { - return RemoteImage( - path, + final url = AppConfig.mediaUrl(path); + if (url.isEmpty) return _placeholder(); + return Image.network( + url, width: double.infinity, fit: BoxFit.fitWidth, - showFallbackIcon: true, - fallbackAspectRatio: 3 / 4, + 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/feed_banner_carousel.dart b/lib/pages/discover/widgets/feed_banner_carousel.dart index 8ad894d..17101bc 100644 --- a/lib/pages/discover/widgets/feed_banner_carousel.dart +++ b/lib/pages/discover/widgets/feed_banner_carousel.dart @@ -4,13 +4,14 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import '../../../core/data/models/home_feed.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/widgets/remote_image.dart'; import '../../../l10n/app_localizations.dart'; +const double _carouselHeight = 154; const double _indicatorHeight = 24; const double _dotSize = 6; const double _activeDotWidth = 18; @@ -115,6 +116,9 @@ class _FeedBannerCarouselState extends State { @override Widget build(BuildContext context) { if (widget.banners.isEmpty) return const SizedBox.shrink(); + final height = widget.dramaFor == null + ? _carouselHeight + : AppDimens.heroBannerHeight; return Listener( key: const Key('feed-banner-carousel'), onPointerDown: (_) => _pause(), @@ -123,7 +127,7 @@ class _FeedBannerCarouselState extends State { child: Column( children: [ SizedBox( - height: AppDimens.heroBannerHeight, + height: height, child: PageView.builder( controller: _controller, itemCount: widget.banners.length, @@ -147,20 +151,22 @@ class _FeedBannerCarouselState extends State { fit: StackFit.expand, children: [ RemoteImage(banner.imageUrl, showFallbackIcon: true), - const DecoratedBox( + DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, - colors: [Color(0x120B0C10), Color(0xE60B0C10)], + colors: drama == null + ? const [Color(0x00000000), Color(0x8A0B0C10)] + : const [Color(0x120B0C10), Color(0xE60B0C10)], ), ), ), if (drama != null) Positioned( - left: 16, - right: 64, - bottom: 14, + left: AppDimens.pageHorizontal, + right: _copyRightGap, + bottom: AppDimens.cardPadding, child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, @@ -244,6 +250,8 @@ class _FeedBannerCarouselState extends State { } } +const double _copyRightGap = 64; + class _PlayMark extends StatelessWidget { const _PlayMark(); diff --git a/lib/pages/home/home_browse_view.dart b/lib/pages/home/home_browse_view.dart index a13408d..724e43c 100644 --- a/lib/pages/home/home_browse_view.dart +++ b/lib/pages/home/home_browse_view.dart @@ -10,16 +10,15 @@ import '../../core/data/models/home_feed.dart'; import '../../core/data/models/watch_progress.dart'; import '../../core/data/repositories/watch_history_repository.dart'; import '../../core/data/services/catalog_controller.dart'; -import '../../core/data/services/category_controller.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_dimens.dart'; import '../../core/widgets/empty_state.dart'; import '../../l10n/app_localizations.dart'; -import '../discover/widgets/drama_grid.dart'; import '../discover/widgets/feed_banner_carousel.dart'; +import '../mine/widgets/continue_watching_card.dart'; import 'home_browse_logic.dart'; import 'widgets/home_browse_header.dart'; -import 'widgets/home_continue_watching.dart'; +import 'widgets/home_drama_grid.dart'; /// Browse-first home page with channels, hero, continuation, and catalogue. class HomeBrowseView extends StatefulWidget { @@ -77,11 +76,7 @@ class _HomeBrowseViewState extends State { Widget _page(BuildContext context) { final l10n = AppLocalizations.of(context)!; - final categories = context.watch(); final catalog = context.watch(); - final source = categories.feed.dramas.isEmpty - ? catalog.dramas - : categories.feed.dramas; return SafeArea( bottom: false, child: Column( @@ -90,57 +85,58 @@ class _HomeBrowseViewState extends State { onSearch: widget.onSearch, onProfile: widget.onProfile, ), - Expanded(child: _body(l10n, categories, catalog, source)), + Expanded(child: _body(l10n, catalog)), ], ), ); } - Widget _body( - AppLocalizations l10n, - CategoryController categories, - CatalogController catalog, - List source, - ) { + Widget _body(AppLocalizations l10n, CatalogController catalog) { return RefreshIndicator( key: const Key('home-refresh'), color: AppColors.blue, backgroundColor: AppColors.card, onRefresh: () async { - await Future.wait([categories.load(), logic.refreshRecent()]); + await Future.wait([catalog.load(), logic.refreshRecent()]); }, child: ListView( key: const Key('home-browse-page'), physics: const AlwaysScrollableScrollPhysics(), padding: const EdgeInsets.only(bottom: AppDimens.spaceXl), - children: _sections(l10n, catalog, source), + children: _sections(l10n, catalog), ), ); } - List _sections( - AppLocalizations l10n, - CatalogController catalog, - List source, - ) { + List _sections(AppLocalizations l10n, CatalogController catalog) { + final source = catalog.dramas; final dramas = _channelDramas(source, logic.channel); - final continueItems = _continueItems(logic.recent, catalog.dramas); + final continueItem = _continueItem(logic.recent, catalog.dramas); + final banners = catalog.banners.isEmpty + ? _fallbackBanners(source) + : catalog.banners; return [ _channels(l10n), - if (catalog.banners.isNotEmpty) _hero(catalog.banners, source), - if (continueItems.isNotEmpty) ...[ + if (banners.isNotEmpty) _hero(banners, source), + if (continueItem != null) ...[ _continueHeader(l10n), - HomeContinueWatching( - items: continueItems, - onPlay: (drama, record) => - unawaited(widget.onOpenContinue(drama, record)), + ContinueWatchingCard( + cardKey: const Key('home-continue-watching'), + playButtonKey: Key('home-continue-play-${continueItem.drama.id}'), + progressKey: Key('home-continue-progress-${continueItem.drama.id}'), + showRemaining: true, + drama: continueItem.drama, + record: continueItem.record, + onResume: () => unawaited( + widget.onOpenContinue(continueItem.drama, continueItem.record), + ), ), ], _channelHeader(l10n), if (dramas.isEmpty) _empty() else - DramaGrid( + HomeDramaGrid( l10n: l10n, dramas: dramas, onOpen: (drama) => unawaited(widget.onOpenDrama(drama)), @@ -148,6 +144,19 @@ class _HomeBrowseViewState extends State { ]; } + List _fallbackBanners(List dramas) { + if (dramas.isEmpty) return const []; + final drama = dramas.first; + return [ + FeedBanner( + id: 'home-fallback-${drama.id}', + imageUrl: drama.posterUrl, + linkType: 'DRAMA', + linkValue: drama.id, + ), + ]; + } + Widget _continueHeader(AppLocalizations l10n) => _sectionHeader( l10n.continueWatching, actionKey: const Key('home-continue-all'), @@ -314,20 +323,18 @@ class _HomeBrowseViewState extends State { return null; } - List _continueItems( + ({Drama drama, WatchProgress record})? _continueItem( List records, List dramas, ) { - final items = []; for (final record in records) { if (record.finished || !record.hasPosition) continue; for (final drama in dramas) { if (drama.id == record.dramaId) { - items.add((drama: drama, record: record)); - break; + return (drama: drama, record: record); } } } - return items; + return null; } } diff --git a/lib/pages/home/widgets/home_continue_watching.dart b/lib/pages/home/widgets/home_continue_watching.dart deleted file mode 100644 index 10544cb..0000000 --- a/lib/pages/home/widgets/home_continue_watching.dart +++ /dev/null @@ -1,147 +0,0 @@ -// Author: fruit.com.cn - -import 'package:flutter/material.dart'; - -import '../../../core/data/models/drama.dart'; -import '../../../core/data/models/watch_progress.dart'; -import '../../../core/theme/app_colors.dart'; -import '../../../core/theme/app_dimens.dart'; -import '../../../core/widgets/remote_image.dart'; -import '../../../l10n/app_localizations.dart'; - -const double _railHeight = 96; -const double _cardWidth = 286; -const double _coverWidth = 64; -const double _playSize = 34; - -/// One drama/record pair rendered by the continuation rail. -typedef HomeContinueItem = ({Drama drama, WatchProgress record}); - -/// Home continuation cards for records that still have unwatched time. -class HomeContinueWatching extends StatelessWidget { - const HomeContinueWatching({ - super.key, - required this.items, - required this.onPlay, - }); - - final List items; - final void Function(Drama drama, WatchProgress record) onPlay; - - @override - Widget build(BuildContext context) { - if (items.isEmpty) return const SizedBox.shrink(); - return SizedBox( - key: const Key('home-continue-watching'), - height: _railHeight, - child: ListView.separated( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric( - horizontal: AppDimens.pageHorizontal, - ), - itemCount: items.length, - separatorBuilder: (_, _) => const SizedBox(width: AppDimens.spaceSm), - itemBuilder: (context, index) { - final item = items[index]; - return _ContinueCard( - item: item, - onPlay: () => onPlay(item.drama, item.record), - ); - }, - ), - ); - } -} - -class _ContinueCard extends StatelessWidget { - const _ContinueCard({required this.item, required this.onPlay}); - - final HomeContinueItem item; - final VoidCallback onPlay; - - @override - Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context)!; - return Container( - width: _cardWidth, - padding: const EdgeInsets.all(AppDimens.spaceSm), - decoration: BoxDecoration( - color: AppColors.card, - borderRadius: BorderRadius.circular(AppDimens.cardRadius), - ), - child: Row( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(AppDimens.innerRadius), - child: RemoteImage( - item.drama.posterUrl, - width: _coverWidth, - height: _railHeight - AppDimens.spaceLg, - ), - ), - const SizedBox(width: AppDimens.spaceSm), - Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - item.drama.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.onSurface, - fontSize: AppDimens.previewCardTitle, - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: AppDimens.spaceXs), - Text( - _progressText(l10n, item.drama, item.record), - key: Key('home-continue-progress-${item.drama.id}'), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.muted, - fontSize: AppDimens.cardMeta, - ), - ), - ], - ), - ), - const SizedBox(width: AppDimens.spaceSm), - IconButton( - key: Key('home-continue-play-${item.drama.id}'), - onPressed: onPlay, - style: IconButton.styleFrom( - backgroundColor: AppColors.blue, - foregroundColor: AppColors.onPrimary, - fixedSize: const Size.square(_playSize), - padding: EdgeInsets.zero, - ), - icon: const Icon( - Icons.play_arrow_rounded, - size: AppDimens.accountIcon, - ), - ), - ], - ), - ); - } - - String _progressText( - AppLocalizations l10n, - Drama drama, - WatchProgress record, - ) { - final episode = drama.episodes.where( - (value) => value.id == record.episodeId, - ); - final number = episode.isEmpty ? 1 : episode.first.number; - final remaining = record.duration - record.position; - final minutes = remaining.inSeconds <= 0 - ? 0 - : (remaining.inSeconds / Duration.secondsPerMinute).ceil(); - return l10n.episodeRemaining(number, minutes); - } -} diff --git a/lib/pages/home/widgets/home_drama_grid.dart b/lib/pages/home/widgets/home_drama_grid.dart new file mode 100644 index 0000000..3a884a8 --- /dev/null +++ b/lib/pages/home/widgets/home_drama_grid.dart @@ -0,0 +1,188 @@ +// 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_dimens.dart'; +import '../../../core/widgets/remote_image.dart'; +import '../../../l10n/app_localizations.dart'; + +const int _columns = 2; +const double _columnGap = 10; +const double _cardGap = 10; +const double _coverAspectRatio = 3 / 4; + +/// Two-column drama grid used by the browse-first home page. +class HomeDramaGrid extends StatelessWidget { + const HomeDramaGrid({ + 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-popular-grid'), + padding: const EdgeInsets.symmetric(horizontal: AppDimens.pageHorizontal), + child: Row( + 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( + HomeDramaCard(l10n: l10n, drama: drama, onTap: () => onOpen(drama)), + ); + if (index + _columns < dramas.length) { + children.add(const SizedBox(height: _cardGap)); + } + } + return Column(children: children); + } +} + +/// Home catalogue card with title, genre, free badge, episode count, and heat. +class HomeDramaCard extends StatelessWidget { + const HomeDramaCard({ + 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(AppDimens.coverRadius), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _cover(), + const SizedBox(height: AppDimens.spaceXs), + _title(), + const SizedBox(height: 2), + _genre(), + const SizedBox(height: AppDimens.spaceXs), + _metadata(), + ], + ), + ); + } + + Widget _cover() { + return ClipRRect( + key: Key('home-drama-cover-${drama.id}'), + borderRadius: BorderRadius.circular(AppDimens.coverRadius), + child: Stack( + children: [ + RemoteImage( + drama.posterUrl, + width: double.infinity, + fit: BoxFit.fitWidth, + showFallbackIcon: true, + fallbackAspectRatio: _coverAspectRatio, + ), + if (_isFree) Positioned(top: 6, left: 6, child: _freeBadge()), + ], + ), + ); + } + + bool get _isFree { + return drama.freeEpisodes > 0 || + drama.episodes.any((episode) => episode.isFree); + } + + 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: AppDimens.cardMeta, + 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: AppDimens.previewSectionTitle, + 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: AppColors.muted, + fontSize: AppDimens.cardMeta, + ), + ); + } + + Widget _metadata() { + return Wrap( + spacing: AppDimens.spaceSm, + runSpacing: AppDimens.spaceXs, + children: [ + Text( + l10n.episodeCount(drama.episodeCount), + key: Key('home-drama-episode-count-${drama.id}'), + style: const TextStyle( + color: AppColors.subtle, + fontSize: AppDimens.cardViews, + ), + ), + Text( + l10n.viewCountLabel(drama.viewCount), + key: Key('home-drama-views-${drama.id}'), + style: const TextStyle( + color: AppColors.subtle, + fontSize: AppDimens.cardViews, + ), + ), + ], + ); + } +} diff --git a/lib/pages/home/widgets/home_menu_sheet.dart b/lib/pages/home/widgets/home_menu_sheet.dart index 80b4f3a..0a255b3 100644 --- a/lib/pages/home/widgets/home_menu_sheet.dart +++ b/lib/pages/home/widgets/home_menu_sheet.dart @@ -74,7 +74,7 @@ class HomeMenuSheet extends StatelessWidget { _item( key: const Key('drawer-discover'), icon: Icons.explore_outlined, - label: l10n.theaterTab, + label: l10n.discoverTab, onTap: () => closeThen(onDiscover), ), _item( diff --git a/lib/pages/mine/widgets/continue_watching_card.dart b/lib/pages/mine/widgets/continue_watching_card.dart index 357f595..e849a8d 100644 --- a/lib/pages/mine/widgets/continue_watching_card.dart +++ b/lib/pages/mine/widgets/continue_watching_card.dart @@ -15,17 +15,25 @@ class ContinueWatchingCard extends StatelessWidget { required this.drama, required this.record, required this.onResume, + this.cardKey = const Key('mine-continue-watching'), + this.playButtonKey = const Key('mine-continue-play'), + this.progressKey, + this.showRemaining = false, }); final Drama drama; final WatchProgress record; final VoidCallback onResume; + final Key cardKey; + final Key playButtonKey; + final Key? progressKey; + final bool showRemaining; @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; return Container( - key: const Key('mine-continue-watching'), + key: cardKey, margin: const EdgeInsets.symmetric(horizontal: 14), padding: const EdgeInsets.all(9), decoration: BoxDecoration( @@ -45,7 +53,7 @@ class ContinueWatchingCard extends StatelessWidget { width: 60, height: 28, child: FilledButton( - key: const Key('mine-continue-play'), + key: playButtonKey, onPressed: onResume, style: FilledButton.styleFrom( backgroundColor: AppColors.blue, @@ -90,6 +98,7 @@ class ContinueWatchingCard extends StatelessWidget { const SizedBox(height: 2), Text( _progressText(l10n), + key: progressKey, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(color: Color(0xFFBBBBBB), fontSize: 9), @@ -100,6 +109,14 @@ class ContinueWatchingCard extends StatelessWidget { String _progressText(AppLocalizations l10n) { final episode = drama.episodes.where((item) => item.id == record.episodeId); + if (showRemaining) { + final number = episode.isEmpty ? 1 : episode.first.number; + final remaining = record.duration - record.position; + final minutes = remaining.inSeconds <= 0 + ? 0 + : (remaining.inSeconds / Duration.secondsPerMinute).ceil(); + return l10n.episodeRemaining(number, minutes); + } final label = episode.isEmpty ? l10n.episodeCount(drama.episodeCount) : l10n.episodeOfTotal(episode.first.number, drama.episodeCount); diff --git a/test/discover/home_all_dramas_grid_test.dart b/test/discover/home_all_dramas_grid_test.dart index 81b001b..fc99d42 100644 --- a/test/discover/home_all_dramas_grid_test.dart +++ b/test/discover/home_all_dramas_grid_test.dart @@ -24,27 +24,37 @@ void main() { InMemorySharedPreferencesAsync.empty(); }); - testWidgets('should_show_search_category_language_and_drama_grid', ( + testWidgets('should_show_search_categories_ranking_and_all_dramas', ( tester, ) async { await _pumpTheater(tester, categoryRepository: _GridCategoryRepository()); expect(find.text('Continue Watching'), findsNothing); expect(find.byKey(const Key('discover-search')), findsOneWidget); - expect(find.byKey(const Key('theater-category-chips')), findsOneWidget); - expect(find.byKey(const Key('theater-language-chips')), 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); + + 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_filter_grid_when_category_chip_is_tapped', ( + testWidgets('should_open_category_dramas_page_when_tile_is_tapped', ( tester, ) async { await _pumpTheater(tester, categoryRepository: _GridCategoryRepository()); - await tester.tap(find.byKey(const Key('theater-category-7'))); + 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, @@ -66,23 +76,22 @@ void main() { ); final headerBefore = tester.getTopLeft( - find.byKey(const Key('discover-search')), + find.byKey(const Key('discover-search-button')), ); - final scrollable = find.descendant( - of: find.byKey(const Key('discover-refresh')), - matching: find.byType(Scrollable), + final contentBefore = tester.getTopLeft( + find.byKey(const Key('discover-category-grid')), ); await tester.drag(find.byType(ListView), const Offset(0, -140)); await tester.pumpAndSettle(); expect( - tester.getTopLeft(find.byKey(const Key('discover-search'))), + tester.getTopLeft(find.byKey(const Key('discover-search-button'))), headerBefore, ); expect( - tester.state(scrollable).position.pixels, - greaterThan(0), + tester.getTopLeft(find.byKey(const Key('discover-category-grid'))).dy, + lessThan(contentBefore.dy), ); }); diff --git a/test/home/k28_a_navigation_test.dart b/test/home/k28_a_navigation_test.dart index cc142da..fd6b428 100644 --- a/test/home/k28_a_navigation_test.dart +++ b/test/home/k28_a_navigation_test.dart @@ -68,35 +68,15 @@ void main() { expect(field.focusNode?.hasFocus, isTrue); }); - testWidgets('should_select_theater_language_without_filtering_catalog', ( - tester, - ) async { - await _pumpApp(tester); - await tester.tap(find.byKey(const Key('tab-theater'))); - await tester.pumpAndSettle(); - - expect(_chip(tester, 'theater-language-all').selected, isTrue); - expect( - find.byKey(const Key('home-drama-card-the-signal-demo')), - findsOneWidget, - ); - - await tester.tap(find.byKey(const Key('theater-language-id'))); - await tester.pumpAndSettle(); - - expect(_chip(tester, 'theater-language-all').selected, isFalse); - expect(_chip(tester, 'theater-language-id').selected, isTrue); - expect( - find.byKey(const Key('home-drama-card-the-signal-demo')), - findsOneWidget, - ); - }); - testWidgets('should_open_fullscreen_player_when_home_card_is_tapped', ( tester, ) async { await _pumpApp(tester); + await tester.ensureVisible( + find.byKey(const Key('home-drama-card-the-signal-demo')), + ); + await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('home-drama-card-the-signal-demo'))); await tester.pumpAndSettle(); @@ -104,6 +84,20 @@ void main() { expect(player.homeMode, isFalse); }); + testWidgets('should_use_first_drama_cover_when_home_has_no_banner', ( + tester, + ) async { + await _pumpApp(tester, withBanner: false); + + expect(find.byKey(const Key('home-hero-banner')), findsOneWidget); + expect(find.byKey(const Key('home-hero-title-0')), findsOneWidget); + await tester.tap(find.byKey(const Key('feed-banner-0'))); + await tester.pumpAndSettle(); + + final player = tester.widget(find.byType(PlayerPage)); + expect(player.homeMode, isFalse); + }); + testWidgets('should_open_fullscreen_player_when_theater_card_is_tapped', ( tester, ) async { @@ -111,7 +105,20 @@ void main() { await tester.tap(find.byKey(const Key('tab-theater'))); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('home-drama-card-the-signal-demo'))); + final card = find.byKey(const Key('home-drama-card-the-signal-demo')); + await tester.scrollUntilVisible( + card, + 220, + scrollable: find + .descendant( + of: find.byKey(const Key('discover-refresh')), + matching: find.byType(Scrollable), + ) + .first, + ); + await tester.ensureVisible(card); + await tester.pumpAndSettle(); + await tester.tap(card); await tester.pumpAndSettle(); final player = tester.widget(find.byType(PlayerPage)); @@ -136,6 +143,7 @@ void main() { ); expect(find.byKey(const Key('home-continue-watching')), findsOneWidget); + expect(find.text('Episode 3 · 8 min left'), findsOneWidget); await tester.tap( find.byKey(const Key('home-continue-play-the-signal-demo')), ); @@ -155,7 +163,8 @@ void main() { await tester.tap(find.byKey(const Key('tab-theater'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('theater-language-chips')), findsOneWidget); + expect(find.byKey(const Key('discover-category-grid')), findsOneWidget); + expect(find.byKey(const Key('discover-ranking-section')), findsOneWidget); expect(tester.takeException(), isNull); }); } @@ -174,20 +183,23 @@ Future _pumpApp( WidgetTester tester, { WatchHistoryRepository? history, Size size = const Size(390, 844), + bool withBanner = true, }) async { tester.view.physicalSize = size; tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); final catalog = FakeCatalogRepository( - banners: const [ - FeedBanner( - id: 'banner-1', - imageUrl: '/files/banner.jpg', - linkType: 'DRAMA', - linkValue: 'the-signal-demo', - ), - ], + banners: withBanner + ? const [ + FeedBanner( + id: 'banner-1', + imageUrl: '/files/banner.jpg', + linkType: 'DRAMA', + linkValue: 'the-signal-demo', + ), + ] + : const [], ); await tester.pumpWidget( LumaDramaApp( diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index f18776f..11b4daa 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -309,7 +309,20 @@ void _expectFullscreenOverlay(WidgetTester tester) { Future _openSeriesPlayer(WidgetTester tester) async { await tester.tap(find.byKey(const Key('tab-theater'))); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('home-drama-card-city-lights-demo'))); + final card = find.byKey(const Key('home-drama-card-city-lights-demo')); + await tester.scrollUntilVisible( + card, + 220, + scrollable: find + .descendant( + of: find.byKey(const Key('discover-refresh')), + matching: find.byType(Scrollable), + ) + .first, + ); + await tester.ensureVisible(card); + await tester.pumpAndSettle(); + await tester.tap(card); await tester.pumpAndSettle(); } diff --git a/test/widget_test.dart b/test/widget_test.dart index 09849bc..2c93dbf 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -84,6 +84,10 @@ void main() { ) async { await tester.pumpWidget(appUnderTest()); await tester.pumpAndSettle(); + await tester.ensureVisible( + find.byKey(const Key('home-drama-card-the-signal-demo')), + ); + await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('home-drama-card-the-signal-demo'))); await tester.pumpAndSettle(); @@ -172,7 +176,7 @@ void main() { expect(tester.takeException(), isNull); }); - testWidgets('should_render_theater_with_filters_and_same_route_tabs', ( + testWidgets('should_render_existing_theater_layout_on_same_route_tab', ( tester, ) async { tester.view.physicalSize = const Size(360, 720); @@ -185,15 +189,11 @@ void main() { await tester.tap(find.byKey(const Key('tab-theater'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('discover-search')), findsOneWidget); - expect(find.byKey(const Key('theater-category-chips')), findsOneWidget); - expect(find.byKey(const Key('theater-language-chips')), 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-search-button')), findsOneWidget); expect(find.byKey(const Key('discover-profile')), findsOneWidget); - expect( - find.byKey(const Key('home-drama-card-city-lights-demo')), - findsOneWidget, - ); expect(tester.takeException(), isNull); }); @@ -287,7 +287,20 @@ void main() { Future _openTheaterPlayer(WidgetTester tester) async { await tester.tap(find.byKey(const Key('tab-theater'))); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('home-drama-card-city-lights-demo'))); + final card = find.byKey(const Key('home-drama-card-city-lights-demo')); + await tester.scrollUntilVisible( + card, + 220, + scrollable: find + .descendant( + of: find.byKey(const Key('discover-refresh')), + matching: find.byType(Scrollable), + ) + .first, + ); + await tester.ensureVisible(card); + await tester.pumpAndSettle(); + await tester.tap(card); await tester.pumpAndSettle(); }