diff --git a/lib/core/theme/app_dimens.dart b/lib/core/theme/app_dimens.dart index 83e6116..6808229 100644 --- a/lib/core/theme/app_dimens.dart +++ b/lib/core/theme/app_dimens.dart @@ -60,8 +60,10 @@ abstract final class AppDimens { static const double accountLabel = 11; static const double watchPlaceholderHeight = 114; - static const double heroBannerHeight = 176; + static const double heroBannerHeight = 240; static const double heroBannerRadius = 20; + static const double heroTitle = 28; + static const double heroSubtitle = 14; static const double cardMeta = 12; static const double cardViews = 11; diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index c1077e2..94fd0bd 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -116,6 +116,8 @@ "recommendedTab": "Recommended", "noCategoryDramas": "No dramas in this category yet.", "allDramas": "All Dramas", + "allLanguages": "All languages", + "bahasaMelayu": "Malay", "createSubtitle": "Stories become short dramas", "weekdayMon": "Mon", "weekdayTue": "Tue", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index f431983..d8c5f60 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -116,6 +116,8 @@ "recommendedTab": "Rekomendasi", "noCategoryDramas": "Belum ada drama di kategori ini.", "allDramas": "Semua Drama", + "allLanguages": "Semua bahasa", + "bahasaMelayu": "Bahasa Melayu", "createSubtitle": "Cerita menjadi drama pendek", "weekdayMon": "Sen", "weekdayTue": "Sel", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 298338c..04fc45b 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -562,6 +562,18 @@ 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: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 18d075b..ee969f7 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -258,6 +258,12 @@ 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'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index fcb7857..0280e21 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -258,6 +258,12 @@ 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'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 4990bb9..489c4ab 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -253,6 +253,12 @@ class AppLocalizationsZh extends AppLocalizations { @override String get allDramas => '全部剧目'; + @override + String get allLanguages => '全部语言'; + + @override + String get bahasaMelayu => '马来语'; + @override String get createSubtitle => '让故事成为漫剧'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 111cb7c..8d474f2 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -116,6 +116,8 @@ "recommendedTab": "推荐", "noCategoryDramas": "这个分类暂时没有剧集。", "allDramas": "全部剧目", + "allLanguages": "全部语言", + "bahasaMelayu": "马来语", "createSubtitle": "让故事成为漫剧", "weekdayMon": "周一", "weekdayTue": "周二", diff --git a/lib/pages/discover/discover_logic.dart b/lib/pages/discover/discover_logic.dart index 7df910f..9556f36 100644 --- a/lib/pages/discover/discover_logic.dart +++ b/lib/pages/discover/discover_logic.dart @@ -28,6 +28,8 @@ class DiscoverLogic extends ChangeNotifier { String _lastRequestedQuery = ''; int _searchRequest = 0; bool _disposed = false; + TheaterLanguage language = TheaterLanguage.all; + String? selectedCategoryId; bool get hasSearchQuery => search.text.trim().isNotEmpty; @@ -48,6 +50,20 @@ class DiscoverLogic extends ChangeNotifier { void openSearch() => searchFocus.requestFocus(); + /// 选择语言;当前仅改变视觉选中态,不参与数据过滤。 + void selectLanguage(TheaterLanguage value) { + if (language == value) return; + language = value; + notify(); + } + + /// 选择分类;空值表示全部。 + 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 d369cc9..5899661 100644 --- a/lib/pages/discover/discover_types.dart +++ b/lib/pages/discover/discover_types.dart @@ -7,6 +7,9 @@ import '../../core/data/models/drama.dart'; /// 剧场页与根导航之间的动作。 enum DiscoverPageAction { home, create, my } +/// 剧场语言筛选的视觉选中态。 +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 1d5c5f1..222f127 100644 --- a/lib/pages/discover/discover_view.dart +++ b/lib/pages/discover/discover_view.dart @@ -4,12 +4,8 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; -import '../../core/data/models/content_category.dart'; import '../../core/data/models/drama.dart'; -import '../../core/data/models/home_feed.dart'; -import '../../core/data/repositories/catalog_repository.dart'; import '../../core/data/repositories/search_repository.dart'; -import '../../core/data/services/catalog_controller.dart'; import '../../core/data/services/category_controller.dart'; import '../../core/formatting/error_text.dart'; import '../../core/theme/app_colors.dart'; @@ -19,19 +15,14 @@ import '../../core/widgets/app_bottom_nav.dart'; import '../../core/widgets/empty_state.dart'; import '../../l10n/app_localizations.dart'; import '../drama_detail/drama_detail_view.dart'; -import 'category_dramas_page.dart'; import 'discover_binding.dart'; import 'discover_logic.dart'; import 'discover_types.dart'; -import 'widgets/category_grid.dart'; import 'widgets/discover_header.dart'; -import 'widgets/discover_search_field.dart'; import 'widgets/discover_search_results.dart'; import 'widgets/drama_grid.dart'; -import 'widgets/feed_banner_carousel.dart'; -import 'widgets/ranking_section.dart'; -/// 剧场页:搜索 + 分类九宫格 + 榜单 + 全部剧目。 +/// K28-A2 剧场页:搜索、分类/语言 chips 与双列剧库。 class DiscoverPage extends StatefulWidget { const DiscoverPage({ super.key, @@ -96,13 +87,18 @@ 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(onSearch: logic.openSearch, onProfile: _openProfile), - Expanded(child: _feed(l10n, categories, banners)), + DiscoverHeader( + controller: logic.search, + focusNode: logic.searchFocus, + onChanged: (_) => logic.onSearchChanged(), + loading: logic.searchLoading, + onProfile: _openProfile, + ), + Expanded(child: _feed(l10n, categories)), if (!embedded) _bottomNav(l10n), ], ), @@ -118,58 +114,37 @@ class _DiscoverPageState extends State { ); } - 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), - ], + 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: AppDimens.spaceXl), + children: _content(l10n, categories), + ), ), ); } - 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, - ) { + List _content(AppLocalizations l10n, CategoryController categories) { + if (logic.hasSearchQuery) { + return [ + DiscoverSearchResults( + loading: logic.searchLoading, + error: logic.searchError, + results: logic.searchResults, + onRetry: logic.retrySearch, + onOpen: _openDrama, + ), + ]; + } if (categories.loading && categories.feed.sections.isEmpty) { return [_block(const CircularProgressIndicator(strokeWidth: 2))]; } @@ -188,91 +163,124 @@ class _DiscoverPageState extends State { ), ]; } - return _theaterBlocks(l10n, categories, banners); + return _theaterBlocks(l10n, categories); } List _theaterBlocks( AppLocalizations l10n, CategoryController categories, - List banners, ) { - final dramas = categories.feed.dramas; + final dramas = logic.selectedCategoryId == null + ? categories.feed.dramas + : categories.dramasOf(logic.selectedCategoryId!); return [ - Padding( - padding: const EdgeInsets.only(top: 12), - child: CategoryGrid( - categories: categories.categories, - onTap: (category) => _openCategory(category, categories), - ), - ), - if (banners.isNotEmpty) + _categoryChips(l10n, categories), + _languageChips(l10n), + if (dramas.isEmpty) Padding( - padding: const EdgeInsets.only(top: 12), - child: FeedBannerCarousel( - banners: banners, - active: widget.active, - onOpen: _openBanner, + padding: const EdgeInsets.symmetric(vertical: 40), + child: EmptyState( + key: const Key('discover-empty'), + icon: Icons.movie_filter_outlined, + title: l10n.noStories, + subtitle: l10n.emptyHint, ), - ), - 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), + ) + else + DramaGrid(l10n: l10n, dramas: dramas, onOpen: _openDrama), ]; } - 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 _dramaGrid(AppLocalizations l10n, List dramas) { - return DramaGrid(l10n: l10n, dramas: dramas, onOpen: _openDrama); - } - - Widget _sectionTitle(String title) { + Widget _categoryChips(AppLocalizations l10n, CategoryController categories) { return Padding( - padding: const EdgeInsets.fromLTRB(14, 18, 14, 10), - child: Row( + padding: const EdgeInsets.fromLTRB( + AppDimens.pageHorizontal, + AppDimens.spaceMd, + AppDimens.pageHorizontal, + 0, + ), + child: Wrap( + key: const Key('theater-category-chips'), + spacing: 10, + runSpacing: 10, children: [ - Container( - width: 3, - height: 18, - decoration: BoxDecoration( - color: AppColors.blue, - borderRadius: BorderRadius.circular(2), - ), + _filterChip( + key: const Key('theater-category-all'), + label: l10n.all, + selected: logic.selectedCategoryId == null, + onSelected: () => logic.selectCategory(null), ), - const SizedBox(width: 6), - Expanded( - child: Text( - title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.onSurface, - fontSize: AppDimens.previewSectionTitle, - fontWeight: FontWeight.w800, - ), + 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: 10, + runSpacing: 10, + 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.onSurface, + fontSize: AppDimens.caption, + fontWeight: FontWeight.w700, + ), + shape: const StadiumBorder(), + ); + } + + Widget _block(Widget child) => + SizedBox(height: 280, child: Center(child: child)); + void _openProfile() { final callback = widget.callbacks.onProfile; if (callback != null) { @@ -291,54 +299,17 @@ 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) { 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; - } - } - 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( @@ -352,7 +323,7 @@ class _DiscoverPageState extends State { tabKey: const Key('discover-nav-current'), icon: Icons.explore_outlined, selectedIcon: Icons.explore_rounded, - label: l10n.discoverTab, + label: l10n.theaterTab, selected: true, onTap: () {}, ), @@ -365,8 +336,8 @@ class _DiscoverPageState extends State { ), AppBottomNavItem( tabKey: const Key('discover-nav-my'), - icon: Icons.video_library_outlined, - selectedIcon: Icons.video_library_rounded, + icon: Icons.person_outline_rounded, + selectedIcon: Icons.person_rounded, label: l10n.myTab, onTap: () => _act(DiscoverPageAction.my), ), diff --git a/lib/pages/discover/widgets/discover_header.dart b/lib/pages/discover/widgets/discover_header.dart index c55adb5..bbff9d2 100644 --- a/lib/pages/discover/widgets/discover_header.dart +++ b/lib/pages/discover/widgets/discover_header.dart @@ -3,37 +3,48 @@ import 'package:flutter/material.dart'; import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_dimens.dart'; import '../../../l10n/app_localizations.dart'; +import 'discover_search_field.dart'; -/// 剧场页页头:品牌、搜索与个人页入口。 +/// 剧场页页头:搜索与头像入口。 class DiscoverHeader extends StatelessWidget { const DiscoverHeader({ super.key, - required this.onSearch, + required this.controller, + required this.focusNode, + required this.onChanged, + required this.loading, required this.onProfile, }); - final VoidCallback onSearch; + final TextEditingController controller; + final FocusNode focusNode; + final ValueChanged onChanged; + final bool loading; final VoidCallback onProfile; @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; return SizedBox( - height: 46, + height: AppDimens.contentHeaderHeight, child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 14), + padding: const EdgeInsets.symmetric( + horizontal: AppDimens.pageHorizontal, + ), child: Row( children: [ - _brand(l10n), - const Spacer(), - _HeaderIcon( - key: const Key('discover-search-button'), - icon: Icons.search_rounded, - tooltip: l10n.search, - onTap: onSearch, + Expanded( + child: DiscoverSearchField( + controller: controller, + focusNode: focusNode, + onChanged: onChanged, + loading: loading, + horizontalPadding: 0, + ), ), - const SizedBox(width: 8), + const SizedBox(width: AppDimens.spaceSm), _profile(l10n), ], ), @@ -41,37 +52,6 @@ class DiscoverHeader extends StatelessWidget { ); } - Widget _brand(AppLocalizations l10n) { - return Row( - children: [ - Container( - width: 24, - height: 24, - alignment: Alignment.center, - decoration: BoxDecoration( - color: AppColors.blue, - borderRadius: BorderRadius.circular(7), - ), - child: const Icon( - Icons.play_arrow_rounded, - color: AppColors.onPrimary, - size: 16, - ), - ), - const SizedBox(width: 8), - Text( - l10n.discoverTab, - style: const TextStyle( - fontFamily: 'Outfit', - color: AppColors.onSurface, - fontSize: 15, - fontWeight: FontWeight.w800, - ), - ), - ], - ); - } - Widget _profile(AppLocalizations l10n) { return Semantics( button: true, @@ -81,54 +61,20 @@ class DiscoverHeader extends StatelessWidget { customBorder: const CircleBorder(), onTap: onProfile, child: Container( - width: 36, - height: 36, - decoration: const BoxDecoration( + width: AppDimens.tapTarget, + height: AppDimens.tapTarget, + decoration: BoxDecoration( + color: AppColors.card, shape: BoxShape.circle, - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [AppColors.secondary, AppColors.blue], - ), + border: Border.all(color: AppColors.border), ), child: const Icon( Icons.person_rounded, - color: AppColors.onPrimary, - size: 18, + color: AppColors.onSurface, + size: AppDimens.accountIcon, ), ), ), ); } } - -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: AppColors.raised, - 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 de549af..a9a9aab 100644 --- a/lib/pages/discover/widgets/discover_search_field.dart +++ b/lib/pages/discover/widgets/discover_search_field.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_dimens.dart'; import '../../../l10n/app_localizations.dart'; /// 剧场顶部搜索框。 @@ -13,19 +14,21 @@ 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: const EdgeInsets.symmetric(horizontal: 14), + padding: EdgeInsets.symmetric(horizontal: horizontalPadding), child: SizedBox( - height: 36, + height: AppDimens.searchHeight, child: TextField( key: const Key('discover-search'), controller: controller, @@ -33,13 +36,13 @@ class DiscoverSearchField extends StatelessWidget { onChanged: onChanged, onTapOutside: (_) => focusNode.unfocus(), textInputAction: TextInputAction.search, - style: const TextStyle(color: AppColors.onSurface, fontSize: 13), + style: const TextStyle(color: AppColors.onSurface, fontSize: 14), decoration: InputDecoration( isDense: true, filled: true, fillColor: AppColors.input, hintText: AppLocalizations.of(context)!.searchDiscover, - hintStyle: const TextStyle(color: AppColors.cream, fontSize: 11), + hintStyle: const TextStyle(color: AppColors.subtle, fontSize: 12), prefixIcon: const Icon( Icons.search_rounded, color: AppColors.subtle, @@ -53,11 +56,11 @@ class DiscoverSearchField extends StatelessWidget { : null, contentPadding: const EdgeInsets.symmetric(horizontal: 12), enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(20), + borderRadius: BorderRadius.circular(AppDimens.pillRadius), borderSide: BorderSide.none, ), focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(20), + borderRadius: BorderRadius.circular(AppDimens.pillRadius), borderSide: const BorderSide(color: AppColors.blue, width: 1), ), ), diff --git a/lib/pages/discover/widgets/feed_banner_carousel.dart b/lib/pages/discover/widgets/feed_banner_carousel.dart index 17101bc..28800d8 100644 --- a/lib/pages/discover/widgets/feed_banner_carousel.dart +++ b/lib/pages/discover/widgets/feed_banner_carousel.dart @@ -39,9 +39,7 @@ class FeedBannerCarousel extends StatefulWidget { } class _FeedBannerCarouselState extends State { - final PageController _controller = PageController( - viewportFraction: _viewportFraction, - ); + late final PageController _controller; Timer? _timer; int _index = 0; bool _pressed = false; @@ -50,6 +48,9 @@ class _FeedBannerCarouselState extends State { @override void initState() { super.initState(); + _controller = PageController( + viewportFraction: widget.dramaFor == null ? _viewportFraction : 1, + ); WidgetsBinding.instance.addPostFrameCallback((_) => _syncTimer()); } @@ -116,28 +117,43 @@ 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; + final hero = widget.dramaFor != null; + final height = hero ? AppDimens.heroBannerHeight : _carouselHeight; + final pages = PageView.builder( + controller: _controller, + itemCount: widget.banners.length, + onPageChanged: (value) => setState(() => _index = value), + itemBuilder: (context, index) => _bannerCard(index), + ); return Listener( key: const Key('feed-banner-carousel'), onPointerDown: (_) => _pause(), onPointerUp: (_) => _resume(), onPointerCancel: (_) => _resume(), - child: Column( - children: [ - SizedBox( - height: height, - child: PageView.builder( - controller: _controller, - itemCount: widget.banners.length, - onPageChanged: (value) => setState(() => _index = value), - itemBuilder: (context, index) => _bannerCard(index), + child: hero + ? SizedBox( + height: height, + child: Stack( + children: [ + Positioned.fill(child: pages), + Positioned( + left: 0, + right: 0, + bottom: AppDimens.spaceXs, + child: SizedBox( + height: _indicatorHeight, + child: _indicator(context), + ), + ), + ], + ), + ) + : Column( + children: [ + SizedBox(height: height, child: pages), + SizedBox(height: _indicatorHeight, child: _indicator(context)), + ], ), - ), - SizedBox(height: _indicatorHeight, child: _indicator(context)), - ], - ), ); } @@ -146,7 +162,9 @@ class _FeedBannerCarouselState extends State { final dramaId = banner.dramaId; final drama = widget.dramaFor?.call(banner); final card = ClipRRect( - borderRadius: BorderRadius.circular(AppDimens.authCardRadius), + borderRadius: BorderRadius.circular( + drama == null ? AppDimens.authCardRadius : AppDimens.heroBannerRadius, + ), child: Stack( fit: StackFit.expand, children: [ @@ -158,7 +176,7 @@ class _FeedBannerCarouselState extends State { end: Alignment.bottomCenter, colors: drama == null ? const [Color(0x00000000), Color(0x8A0B0C10)] - : const [Color(0x120B0C10), Color(0xE60B0C10)], + : const [Color(0x00000000), Color(0xE6000000)], ), ), ), @@ -166,7 +184,7 @@ class _FeedBannerCarouselState extends State { Positioned( left: AppDimens.pageHorizontal, right: _copyRightGap, - bottom: AppDimens.cardPadding, + bottom: AppDimens.spaceXl + AppDimens.spaceSm, child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, @@ -179,7 +197,7 @@ class _FeedBannerCarouselState extends State { style: const TextStyle( fontFamily: 'Outfit', color: AppColors.onSurface, - fontSize: AppDimens.pageTitle, + fontSize: AppDimens.heroTitle, fontWeight: FontWeight.w800, ), ), @@ -190,24 +208,28 @@ class _FeedBannerCarouselState extends State { overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.muted, - fontSize: AppDimens.caption, + fontSize: AppDimens.heroSubtitle, ), ), ], ), ), - if (dramaId != null) + if (dramaId != null && drama == null) const Positioned(right: 14, bottom: 14, child: _PlayMark()), ], ), ); final page = Padding( - padding: const EdgeInsets.symmetric(horizontal: 5), + padding: EdgeInsets.symmetric(horizontal: drama == null ? 5 : 0), child: dramaId == null ? card : Material( color: Colors.transparent, - borderRadius: BorderRadius.circular(AppDimens.authCardRadius), + borderRadius: BorderRadius.circular( + drama == null + ? AppDimens.authCardRadius + : AppDimens.heroBannerRadius, + ), clipBehavior: Clip.antiAlias, child: InkWell( key: Key('feed-banner-$index'), diff --git a/lib/pages/home/home_browse_view.dart b/lib/pages/home/home_browse_view.dart index 6782f33..b8a04ea 100644 --- a/lib/pages/home/home_browse_view.dart +++ b/lib/pages/home/home_browse_view.dart @@ -228,7 +228,12 @@ class _HomeBrowseViewState extends State { Widget _hero(List banners, List dramas) { return Padding( - padding: const EdgeInsets.only(top: AppDimens.spaceMd), + padding: const EdgeInsets.fromLTRB( + AppDimens.pageHorizontal, + AppDimens.spaceMd, + AppDimens.pageHorizontal, + 0, + ), child: FeedBannerCarousel( key: const Key('home-hero-banner'), banners: banners, diff --git a/test/discover/discover_banner_test.dart b/test/discover/discover_banner_test.dart index 1a441c5..a5f08a0 100644 --- a/test/discover/discover_banner_test.dart +++ b/test/discover/discover_banner_test.dart @@ -38,6 +38,18 @@ void main() { ); await _pumpApp(tester, catalog); expect(find.byKey(const Key('feed-banner-carousel')), findsOneWidget); + expect( + tester.getSize(find.byKey(const Key('home-hero-banner'))).height, + greaterThanOrEqualTo(230), + ); + expect( + tester + .widget(find.byKey(const Key('home-hero-title-0'))) + .style + ?.fontSize, + 28, + ); + expect(find.byKey(const Key('feed-banner-dot-0')), findsOneWidget); await tester.tap(find.byKey(const Key('feed-banner-0'))); await tester.pumpAndSettle(); diff --git a/test/discover/home_all_dramas_grid_test.dart b/test/discover/home_all_dramas_grid_test.dart index fc99d42..5c638c1 100644 --- a/test/discover/home_all_dramas_grid_test.dart +++ b/test/discover/home_all_dramas_grid_test.dart @@ -24,37 +24,27 @@ void main() { InMemorySharedPreferencesAsync.empty(); }); - testWidgets('should_show_search_categories_ranking_and_all_dramas', ( + testWidgets('should_show_search_category_language_chips_and_drama_grid', ( tester, ) async { await _pumpTheater(tester, categoryRepository: _GridCategoryRepository()); expect(find.text('Continue Watching'), findsNothing); expect(find.byKey(const Key('discover-search')), findsOneWidget); - expect(find.byKey(const Key('discover-category-grid')), findsOneWidget); - expect(find.byKey(const Key('discover-ranking-section')), findsOneWidget); + expect(find.byKey(const Key('theater-category-chips')), findsOneWidget); + expect(find.byKey(const Key('theater-language-chips')), findsOneWidget); expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); - expect(find.byKey(const Key('ranking-tab-hot')), findsOneWidget); - expect(find.byKey(const Key('ranking-tab-new')), findsOneWidget); - - final grid = tester.widget( - find.byKey(const Key('discover-category-grid')), - ); - final delegate = - grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount; - expect(delegate.crossAxisCount, 3); expect(tester.takeException(), isNull); }); - testWidgets('should_open_category_dramas_page_when_tile_is_tapped', ( + testWidgets('should_filter_grid_when_category_chip_is_tapped', ( tester, ) async { await _pumpTheater(tester, categoryRepository: _GridCategoryRepository()); - await tester.tap(find.byKey(const Key('discover-category-7'))); + await tester.tap(find.byKey(const Key('theater-category-7'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('category-dramas-page')), findsOneWidget); expect( find.byKey(const Key('home-drama-card-city-lights-demo')), findsOneWidget, @@ -76,22 +66,23 @@ void main() { ); final headerBefore = tester.getTopLeft( - find.byKey(const Key('discover-search-button')), + find.byKey(const Key('discover-search')), ); - final contentBefore = tester.getTopLeft( - find.byKey(const Key('discover-category-grid')), + final scrollable = find.descendant( + of: find.byKey(const Key('discover-refresh')), + matching: find.byType(Scrollable), ); await tester.drag(find.byType(ListView), const Offset(0, -140)); await tester.pumpAndSettle(); expect( - tester.getTopLeft(find.byKey(const Key('discover-search-button'))), + tester.getTopLeft(find.byKey(const Key('discover-search'))), headerBefore, ); expect( - tester.getTopLeft(find.byKey(const Key('discover-category-grid'))).dy, - lessThan(contentBefore.dy), + tester.state(scrollable).position.pixels, + greaterThan(0), ); }); diff --git a/test/home/k28_a_navigation_test.dart b/test/home/k28_a_navigation_test.dart index 4d18208..0ca92e9 100644 --- a/test/home/k28_a_navigation_test.dart +++ b/test/home/k28_a_navigation_test.dart @@ -79,6 +79,26 @@ 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(_chip(tester, 'theater-language-id').selected, isFalse); + 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 { @@ -174,8 +194,8 @@ void main() { await tester.tap(find.byKey(const Key('tab-theater'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('discover-category-grid')), findsOneWidget); - expect(find.byKey(const Key('discover-ranking-section')), findsOneWidget); + expect(find.byKey(const Key('theater-category-chips')), findsOneWidget); + expect(find.byKey(const Key('theater-language-chips')), findsOneWidget); expect(tester.takeException(), isNull); }); } diff --git a/test/widget_test.dart b/test/widget_test.dart index 2c93dbf..4a12dd3 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -176,7 +176,7 @@ void main() { expect(tester.takeException(), isNull); }); - testWidgets('should_render_existing_theater_layout_on_same_route_tab', ( + testWidgets('should_render_theater_filters_and_grid_on_same_route_tab', ( tester, ) async { tester.view.physicalSize = const Size(360, 720); @@ -189,10 +189,10 @@ void main() { await tester.tap(find.byKey(const Key('tab-theater'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('discover-category-grid')), findsOneWidget); - expect(find.byKey(const Key('discover-ranking-section')), findsOneWidget); + expect(find.byKey(const Key('discover-search')), findsOneWidget); + expect(find.byKey(const Key('theater-category-chips')), findsOneWidget); + expect(find.byKey(const Key('theater-language-chips')), findsOneWidget); expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); - expect(find.byKey(const Key('discover-search-button')), findsOneWidget); expect(find.byKey(const Key('discover-profile')), findsOneWidget); expect(tester.takeException(), isNull); });