diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 692373b..c4de9e9 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -29,6 +29,17 @@ "romance": "Romance", "actionGenre": "Action", "filter": "Filter", + "filterGroupCategory": "All genres", + "filterGroupTheme": "All themes", + "filterGroupLanguage": "All languages", + "filterGroupSort": "All recommendations", + "sortNewest": "Newest", + "sortHottest": "Hottest", + "expandFilters": "Expand", + "collapseFilters": "Collapse", + "filterEmpty": "No dramas match these filters", + "hotBadge": "Hit", + "filterAll": "All", "seriesMeta": "Series · {episodes} eps | {viewers} watching", "@seriesMeta": { "placeholders": { diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 286f866..669b9d3 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -29,6 +29,17 @@ "romance": "Romantis", "actionGenre": "Aksi", "filter": "Filter", + "filterGroupCategory": "Semua genre", + "filterGroupTheme": "Semua tema", + "filterGroupLanguage": "Semua bahasa", + "filterGroupSort": "Semua rekomendasi", + "sortNewest": "Terbaru", + "sortHottest": "Terpopuler", + "expandFilters": "Perluas", + "collapseFilters": "Ciutkan", + "filterEmpty": "Tidak ada drama yang cocok", + "hotBadge": "Hits", + "filterAll": "Semua", "seriesMeta": "Serial · {episodes} eps | {viewers} menonton", "watchFullSeries": "Tonton serial lengkap · {episodes} eps", "feedTabAnime": "Anime", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index d74c2aa..6b99116 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -274,6 +274,72 @@ abstract class AppLocalizations { /// **'Filter'** String get filter; + /// No description provided for @filterGroupCategory. + /// + /// In en, this message translates to: + /// **'All genres'** + String get filterGroupCategory; + + /// No description provided for @filterGroupTheme. + /// + /// In en, this message translates to: + /// **'All themes'** + String get filterGroupTheme; + + /// No description provided for @filterGroupLanguage. + /// + /// In en, this message translates to: + /// **'All languages'** + String get filterGroupLanguage; + + /// No description provided for @filterGroupSort. + /// + /// In en, this message translates to: + /// **'All recommendations'** + String get filterGroupSort; + + /// No description provided for @sortNewest. + /// + /// In en, this message translates to: + /// **'Newest'** + String get sortNewest; + + /// No description provided for @sortHottest. + /// + /// In en, this message translates to: + /// **'Hottest'** + String get sortHottest; + + /// No description provided for @expandFilters. + /// + /// In en, this message translates to: + /// **'Expand'** + String get expandFilters; + + /// No description provided for @collapseFilters. + /// + /// In en, this message translates to: + /// **'Collapse'** + String get collapseFilters; + + /// No description provided for @filterEmpty. + /// + /// In en, this message translates to: + /// **'No dramas match these filters'** + String get filterEmpty; + + /// No description provided for @hotBadge. + /// + /// In en, this message translates to: + /// **'Hit'** + String get hotBadge; + + /// No description provided for @filterAll. + /// + /// In en, this message translates to: + /// **'All'** + String get filterAll; + /// No description provided for @seriesMeta. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 3f7e73d..239ce3d 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -98,6 +98,39 @@ class AppLocalizationsEn extends AppLocalizations { @override String get filter => 'Filter'; + @override + String get filterGroupCategory => 'All genres'; + + @override + String get filterGroupTheme => 'All themes'; + + @override + String get filterGroupLanguage => 'All languages'; + + @override + String get filterGroupSort => 'All recommendations'; + + @override + String get sortNewest => 'Newest'; + + @override + String get sortHottest => 'Hottest'; + + @override + String get expandFilters => 'Expand'; + + @override + String get collapseFilters => 'Collapse'; + + @override + String get filterEmpty => 'No dramas match these filters'; + + @override + String get hotBadge => 'Hit'; + + @override + String get filterAll => 'All'; + @override String seriesMeta(String episodes, String viewers) { return 'Series · $episodes eps | $viewers watching'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 12239da..c7da015 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -98,6 +98,39 @@ class AppLocalizationsId extends AppLocalizations { @override String get filter => 'Filter'; + @override + String get filterGroupCategory => 'Semua genre'; + + @override + String get filterGroupTheme => 'Semua tema'; + + @override + String get filterGroupLanguage => 'Semua bahasa'; + + @override + String get filterGroupSort => 'Semua rekomendasi'; + + @override + String get sortNewest => 'Terbaru'; + + @override + String get sortHottest => 'Terpopuler'; + + @override + String get expandFilters => 'Perluas'; + + @override + String get collapseFilters => 'Ciutkan'; + + @override + String get filterEmpty => 'Tidak ada drama yang cocok'; + + @override + String get hotBadge => 'Hits'; + + @override + String get filterAll => 'Semua'; + @override String seriesMeta(String episodes, String viewers) { return 'Serial · $episodes eps | $viewers menonton'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 8cbbaad..49f554d 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -95,6 +95,39 @@ class AppLocalizationsZh extends AppLocalizations { @override String get filter => '筛选'; + @override + String get filterGroupCategory => '全部体裁'; + + @override + String get filterGroupTheme => '全部主题'; + + @override + String get filterGroupLanguage => '全部语言'; + + @override + String get filterGroupSort => '全部推荐'; + + @override + String get sortNewest => '最新上架'; + + @override + String get sortHottest => '最高热度'; + + @override + String get expandFilters => '展开'; + + @override + String get collapseFilters => '收起'; + + @override + String get filterEmpty => '没有符合条件的短剧'; + + @override + String get hotBadge => '爆剧'; + + @override + String get filterAll => '全部'; + @override String seriesMeta(String episodes, String viewers) { return '系列剧 · 共$episodes集 | 共$viewers人在追'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index c0caaac..8e430b3 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -29,6 +29,17 @@ "romance": "都市爱情", "actionGenre": "动作", "filter": "筛选", + "filterGroupCategory": "全部体裁", + "filterGroupTheme": "全部主题", + "filterGroupLanguage": "全部语言", + "filterGroupSort": "全部推荐", + "sortNewest": "最新上架", + "sortHottest": "最高热度", + "expandFilters": "展开", + "collapseFilters": "收起", + "filterEmpty": "没有符合条件的短剧", + "hotBadge": "爆剧", + "filterAll": "全部", "seriesMeta": "系列剧 · 共{episodes}集 | 共{viewers}人在追", "watchFullSeries": "观看完整剧集 · 全{episodes}集", "feedTabAnime": "漫剧", diff --git a/lib/pages/discover/discover_view.dart b/lib/pages/discover/discover_view.dart index 4927b76..cb68f9d 100644 --- a/lib/pages/discover/discover_view.dart +++ b/lib/pages/discover/discover_view.dart @@ -16,6 +16,7 @@ import '../../core/theme/app_theme.dart'; import '../../core/widgets/app_bottom_nav.dart'; import '../../core/widgets/empty_state.dart'; import '../../l10n/app_localizations.dart'; +import '../filter/filter_page.dart'; import '../new_releases/new_releases_page.dart'; import '../player/open_player.dart'; import '../ranking/ranking_page.dart'; @@ -261,7 +262,7 @@ class _DiscoverPageState extends State { ? categories.feed.dramas : categories.dramasOf(logic.selectedCategoryId!); return [ - _toolbar(l10n, categories), + _toolbar(l10n), if (dramas.isEmpty) Padding( padding: const EdgeInsets.symmetric(vertical: 40), @@ -350,7 +351,7 @@ class _DiscoverPageState extends State { ); } - Widget _toolbar(AppLocalizations l10n, CategoryController categories) { + Widget _toolbar(AppLocalizations l10n) { return Padding( padding: const EdgeInsets.fromLTRB(14, 2, 14, 12), child: Row( @@ -361,7 +362,7 @@ class _DiscoverPageState extends State { icon: Icons.tune_rounded, color: _filterIconColor, label: l10n.filter, - onTap: () => _openFilterSheet(l10n, categories), + onTap: () => _openPage(context, const FilterPage()), ), ), const SizedBox(width: _toolbarGap), @@ -443,43 +444,6 @@ class _DiscoverPageState extends State { ); } - Future _openFilterSheet( - AppLocalizations l10n, - CategoryController categories, - ) async { - await showModalBottomSheet( - context: context, - backgroundColor: AppColors.card, - showDragHandle: true, - builder: (sheetContext) => SafeArea( - child: ListView( - key: const Key('theater-filter-sheet'), - shrinkWrap: true, - padding: const EdgeInsets.only(bottom: 12), - children: [ - ListTile( - key: const Key('theater-filter-option-all'), - title: Text(l10n.all), - onTap: () { - Navigator.pop(sheetContext); - logic.selectCategory(null); - }, - ), - for (final category in categories.categories) - ListTile( - key: Key('theater-filter-option-${category.id}'), - title: Text(category.name), - onTap: () { - Navigator.pop(sheetContext); - logic.selectCategory(category.id); - }, - ), - ], - ), - ), - ); - } - void _openPage(BuildContext context, Widget page) { Navigator.of(context).push(MaterialPageRoute(builder: (_) => page)); } diff --git a/lib/pages/filter/filter_page.dart b/lib/pages/filter/filter_page.dart new file mode 100644 index 0000000..3828c02 --- /dev/null +++ b/lib/pages/filter/filter_page.dart @@ -0,0 +1,463 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; + +import '../../core/data/models/drama.dart'; +import '../../core/data/services/catalog_controller.dart'; +import '../../core/data/services/category_controller.dart'; +import '../../core/theme/app_colors.dart'; +import '../../core/widgets/empty_state.dart'; +import '../../l10n/app_localizations.dart'; +import 'widgets/filter_result_card.dart'; + +const String _allValue = '__all__'; +const double _pageHorizontal = 14; +const double _groupHorizontal = 12; +const double _groupVertical = 9; +const double _labelWidth = 84; +const double _groupGap = 10; +const double _optionHorizontal = 5; +const double _optionVertical = 5; +const double _optionRunGap = 4; +const double _cardGap = 14; +const double _gridAspectRatio = 0.56; +const double _groupFontSize = 13; +const double _labelFontSize = 12; + +final RegExp _tagSplitter = RegExp(r'[,/、·]'); + +/// 剧场筛选页:分组条件实时过滤三列短剧结果。 +class FilterPage extends StatefulWidget { + const FilterPage({super.key}); + + @override + State createState() => _FilterPageState(); +} + +class _FilterPageState extends State { + bool _expanded = false; + String _category = _allValue; + String _theme = _allValue; + String _language = _allValue; + _SortMode _sort = _SortMode.recommended; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final categories = context.watch(); + final catalog = context.watch(); + final categoryOptions = _uniqueValues( + categories.categories.map((category) => category.name), + ); + final themeOptions = _uniqueValues(catalog.dramas.expand(_themeValues)); + final languageOptions = _uniqueValues( + catalog.dramas.map((drama) => drama.language), + ); + final results = _filtered(catalog.dramas); + return AnnotatedRegion( + value: SystemUiOverlayStyle.dark, + child: Scaffold( + key: const Key('filter-page'), + backgroundColor: AppColors.ink, + body: SafeArea( + child: Column( + children: [ + _topBar(l10n), + Expanded( + child: CustomScrollView( + slivers: [ + SliverToBoxAdapter( + child: _groups( + l10n, + categoryOptions, + themeOptions, + languageOptions, + ), + ), + if (results.isEmpty) + SliverFillRemaining( + hasScrollBody: false, + child: EmptyState( + key: const Key('filter-empty'), + icon: Icons.movie_filter_outlined, + title: l10n.filterEmpty, + subtitle: l10n.emptyHint, + ), + ) + else + SliverPadding( + key: const Key('filter-results'), + padding: const EdgeInsets.fromLTRB( + _pageHorizontal, + 10, + _pageHorizontal, + 24, + ), + sliver: SliverGrid( + gridDelegate: + const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + crossAxisSpacing: _cardGap, + mainAxisSpacing: _cardGap, + childAspectRatio: _gridAspectRatio, + ), + delegate: SliverChildBuilderDelegate( + (context, index) => FilterResultCard( + drama: results[index], + l10n: l10n, + categoryLabel: _metaCategory(results[index]), + ), + childCount: results.length, + ), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } + + Widget _topBar(AppLocalizations l10n) { + return SizedBox( + height: 52, + child: Row( + children: [ + SizedBox( + key: const Key('filter-back'), + width: 48, + height: 48, + child: IconButton( + tooltip: MaterialLocalizations.of(context).backButtonTooltip, + onPressed: () => Navigator.of(context).pop(), + padding: EdgeInsets.zero, + icon: const Icon( + Icons.chevron_left_rounded, + color: AppColors.onSurface, + size: 26, + ), + ), + ), + Expanded( + child: Center( + child: Text( + l10n.filter, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 18, + fontWeight: FontWeight.w800, + ), + ), + ), + ), + const SizedBox(width: 48), + ], + ), + ); + } + + Widget _groups( + AppLocalizations l10n, + List categories, + List themes, + List languages, + ) { + return Padding( + padding: const EdgeInsets.fromLTRB( + _pageHorizontal, + 2, + _pageHorizontal, + 0, + ), + child: Column( + key: const Key('filter-groups'), + children: [ + _group( + group: _FilterGroup.category, + label: _selectedLabel(_category, l10n.filterGroupCategory), + values: categories, + selected: _category, + onSelected: (value) => setState(() => _category = value), + allLabel: l10n.filterAll, + ), + _group( + group: _FilterGroup.theme, + label: _selectedLabel(_theme, l10n.filterGroupTheme), + values: themes, + selected: _theme, + onSelected: (value) => setState(() => _theme = value), + allLabel: l10n.filterAll, + ), + AnimatedSize( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOutCubic, + alignment: Alignment.topCenter, + child: _expanded + ? Column( + children: [ + _group( + group: _FilterGroup.language, + label: _languageLabel(_language, l10n), + values: languages, + selected: _language, + onSelected: (value) => + setState(() => _language = value), + allLabel: l10n.filterAll, + optionLabel: (value) => _languageLabel(value, l10n), + ), + _group( + group: _FilterGroup.sort, + label: _sortLabel(_sort, l10n), + values: const ['newest', 'hottest'], + selected: switch (_sort) { + _SortMode.recommended => _allValue, + _SortMode.newest => 'newest', + _SortMode.hottest => 'hottest', + }, + onSelected: (value) => setState(() { + _sort = switch (value) { + 'newest' => _SortMode.newest, + 'hottest' => _SortMode.hottest, + _ => _SortMode.recommended, + }; + }), + allLabel: l10n.filterGroupSort, + optionLabel: (value) => switch (value) { + 'newest' => l10n.sortNewest, + 'hottest' => l10n.sortHottest, + _ => l10n.filterGroupSort, + }, + ), + ], + ) + : const SizedBox.shrink(), + ), + TextButton.icon( + key: const Key('filter-expand'), + onPressed: () => setState(() => _expanded = !_expanded), + style: TextButton.styleFrom( + foregroundColor: AppColors.muted, + minimumSize: const Size(88, 38), + padding: const EdgeInsets.symmetric(horizontal: 12), + ), + iconAlignment: IconAlignment.end, + icon: Icon( + _expanded + ? Icons.keyboard_arrow_up_rounded + : Icons.keyboard_arrow_down_rounded, + size: 18, + ), + label: Text( + _expanded ? l10n.collapseFilters : l10n.expandFilters, + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } + + Widget _group({ + required _FilterGroup group, + required String label, + required List values, + required String selected, + required ValueChanged onSelected, + required String allLabel, + String Function(String value)? optionLabel, + }) { + final options = [ + _FilterOption(key: 'all', value: _allValue, label: allLabel), + for (final value in values) + _FilterOption( + key: value, + value: value, + label: optionLabel?.call(value) ?? value, + ), + ]; + return Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.symmetric( + horizontal: _groupHorizontal, + vertical: _groupVertical, + ), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + key: Key('filter-group-label-${group.name}'), + width: _labelWidth, + constraints: const BoxConstraints(minHeight: 28), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5), + alignment: Alignment.center, + decoration: BoxDecoration( + color: AppColors.badgeBeige, + borderRadius: BorderRadius.circular(7), + ), + child: Text( + label, + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: const TextStyle( + color: AppColors.blue, + fontSize: _labelFontSize, + fontWeight: FontWeight.w700, + height: 1.15, + ), + ), + ), + const SizedBox(width: _groupGap), + Expanded( + child: Wrap( + spacing: 8, + runSpacing: _optionRunGap, + children: [ + for (final option in options) + _option( + group: group, + option: option, + selected: selected == option.value, + onTap: () => onSelected(option.value), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _option({ + required _FilterGroup group, + required _FilterOption option, + required bool selected, + required VoidCallback onTap, + }) { + return GestureDetector( + key: Key('filter-option-${group.name}-${option.key}'), + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: _optionHorizontal, + vertical: _optionVertical, + ), + child: Text( + option.label, + style: TextStyle( + color: selected ? AppColors.blue : AppColors.onSurface, + fontSize: _groupFontSize, + fontWeight: selected ? FontWeight.w800 : FontWeight.w500, + ), + ), + ), + ); + } + + List _filtered(List dramas) { + final result = dramas.where((drama) { + if (_category != _allValue && drama.category != _category) return false; + if (_theme != _allValue && !_themeValues(drama).contains(_theme)) { + return false; + } + if (_language != _allValue && drama.language != _language) return false; + return true; + }).toList(); + switch (_sort) { + case _SortMode.recommended: + break; + case _SortMode.newest: + result.sort((a, b) => _compareIds(b.id, a.id)); + case _SortMode.hottest: + result.sort((a, b) => b.viewCount.compareTo(a.viewCount)); + } + return result; + } + + String _selectedLabel(String value, String fallback) { + if (value == _allValue) return fallback; + return value; + } + + String _languageLabel(String value, AppLocalizations l10n) { + if (value == _allValue) return l10n.filterGroupLanguage; + return _languageNames[value.toLowerCase()] ?? value; + } + + String _sortLabel(_SortMode value, AppLocalizations l10n) { + return switch (value) { + _SortMode.recommended => l10n.filterGroupSort, + _SortMode.newest => l10n.sortNewest, + _SortMode.hottest => l10n.sortHottest, + }; + } + + String _metaCategory(Drama drama) { + if (drama.category.trim().isNotEmpty) return drama.category.trim(); + final themes = _themeValues(drama); + return themes.isEmpty ? '' : themes.first; + } +} + +class _FilterOption { + const _FilterOption({ + required this.key, + required this.value, + required this.label, + }); + + final String key; + final String value; + final String label; +} + +enum _FilterGroup { category, theme, language, sort } + +enum _SortMode { recommended, newest, hottest } + +const Map _languageNames = { + 'zh': '简体中文', + 'zh-cn': '简体中文', + 'zh-hans': '简体中文', + 'en': 'English', + 'en-us': 'English', + 'id': 'Bahasa Indonesia', + 'id-id': 'Bahasa Indonesia', +}; + +List _themeValues(Drama drama) { + final values = []; + for (final source in [drama.genre, drama.category]) { + for (final part in source.split(_tagSplitter)) { + final value = part.trim(); + if (value.isNotEmpty && !values.contains(value)) values.add(value); + } + } + return values; +} + +List _uniqueValues(Iterable source) { + final result = []; + for (final raw in source) { + final value = raw.trim(); + if (value.isNotEmpty && !result.contains(value)) result.add(value); + } + return result; +} + +int _compareIds(String a, String b) { + final numberA = int.tryParse(a); + final numberB = int.tryParse(b); + if (numberA != null && numberB != null) return numberA.compareTo(numberB); + return a.compareTo(b); +} diff --git a/lib/pages/filter/widgets/filter_result_card.dart b/lib/pages/filter/widgets/filter_result_card.dart new file mode 100644 index 0000000..d51a8a2 --- /dev/null +++ b/lib/pages/filter/widgets/filter_result_card.dart @@ -0,0 +1,122 @@ +// Author: fruit.com.cn + +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../../../core/data/models/drama.dart'; +import '../../../core/formatting/count_format.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../core/widgets/remote_image.dart'; +import '../../../l10n/app_localizations.dart'; +import '../../player/open_player.dart'; + +const double _coverRadius = 10; +const double _titleGap = 6; +const double _metaGap = 2; +const double _badgeHorizontal = 5; +const double _badgeVertical = 2; +const double _badgeRadius = 6; +const double _titleFontSize = 12.5; +const double _metaFontSize = 11; +const double _badgeFontSize = 9; + +/// 筛选结果三列卡片:封面、标题、集数与热度和分类元信息。 +class FilterResultCard extends StatelessWidget { + const FilterResultCard({ + super.key, + required this.drama, + required this.l10n, + required this.categoryLabel, + }); + + final Drama drama; + final AppLocalizations l10n; + final String categoryLabel; + + @override + Widget build(BuildContext context) { + return GestureDetector( + key: Key('filter-card-${drama.id}'), + behavior: HitTestBehavior.opaque, + onTap: () => unawaited(playDramaFromCatalog(context, drama)), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(_coverRadius), + child: AspectRatio( + aspectRatio: 3 / 4, + child: Stack( + fit: StackFit.expand, + children: [ + RemoteImage( + drama.posterUrl, + fit: BoxFit.cover, + showFallbackIcon: true, + fallbackAspectRatio: 3 / 4, + ), + if (drama.isHot) + Positioned( + top: 6, + right: 6, + child: Container( + key: Key('filter-badge-hot-${drama.id}'), + padding: const EdgeInsets.symmetric( + horizontal: _badgeHorizontal, + vertical: _badgeVertical, + ), + decoration: BoxDecoration( + color: AppColors.blue, + borderRadius: BorderRadius.circular(_badgeRadius), + ), + child: Text( + l10n.hotBadge, + style: const TextStyle( + color: AppColors.onPrimary, + fontSize: _badgeFontSize, + fontWeight: FontWeight.w800, + ), + ), + ), + ), + ], + ), + ), + ), + const SizedBox(height: _titleGap), + Text( + drama.title, + key: Key('filter-title-${drama.id}'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: _titleFontSize, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: _metaGap), + Text( + _meta(), + key: Key('filter-meta-${drama.id}'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.muted, + fontSize: _metaFontSize, + ), + ), + ], + ), + ); + } + + String _meta() { + return [ + l10n.episodeCountLabel(drama.episodeCount), + formatInteractionCount(drama.viewCount), + if (categoryLabel.isNotEmpty) categoryLabel, + ].join(' · '); + } +} diff --git a/test/discover/filter_page_test.dart b/test/discover/filter_page_test.dart new file mode 100644 index 0000000..cbfea3b --- /dev/null +++ b/test/discover/filter_page_test.dart @@ -0,0 +1,218 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/app.dart'; +import 'package:fruitshorts/core/data/models/content_category.dart'; +import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; +import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; +import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; +import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; + +import '../support/fake_catalog.dart'; +import '../support/fake_category.dart'; +import '../support/fake_haptics.dart'; +import '../support/fake_interaction_repositories.dart'; +import '../support/widget_player.dart'; + +void main() { + setUp(() { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + }); + + testWidgets('should_expand_all_filter_groups_when_expand_is_tapped', ( + tester, + ) async { + await _pumpFilterPage(tester); + + expect(find.byKey(const Key('filter-page')), findsOneWidget); + expect( + find.byKey(const Key('filter-group-label-category')), + findsOneWidget, + ); + expect(find.byKey(const Key('filter-group-label-theme')), findsOneWidget); + expect(find.byKey(const Key('filter-group-label-language')), findsNothing); + expect(find.byKey(const Key('filter-group-label-sort')), findsNothing); + + await tester.tap(find.byKey(const Key('filter-expand'))); + await tester.pumpAndSettle(); + + expect( + find.byKey(const Key('filter-group-label-language')), + findsOneWidget, + ); + expect(find.byKey(const Key('filter-group-label-sort')), findsOneWidget); + }); + + testWidgets('should_filter_and_sort_dramas_when_options_are_tapped', ( + tester, + ) async { + await _pumpFilterPage(tester); + await tester.tap(find.byKey(const Key('filter-expand'))); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('filter-option-category-Romance'))); + await tester.pumpAndSettle(); + + expect( + find.descendant( + of: find.byKey(const Key('filter-group-label-category')), + matching: find.text('Romance'), + ), + findsOneWidget, + ); + expect(find.byKey(const Key('filter-card-1001')), findsOneWidget); + expect(find.byKey(const Key('filter-card-1003')), findsOneWidget); + expect(find.byKey(const Key('filter-card-1002')), findsNothing); + + await tester.tap(find.byKey(const Key('filter-option-sort-hottest'))); + await tester.pumpAndSettle(); + + final hottestFirst = tester.getTopLeft( + find.byKey(const Key('filter-card-1001')), + ); + final hottestSecond = tester.getTopLeft( + find.byKey(const Key('filter-card-1003')), + ); + expect(hottestFirst.dx, lessThan(hottestSecond.dx)); + + await tester.tap(find.byKey(const Key('filter-option-sort-newest'))); + await tester.pumpAndSettle(); + + final newestFirst = tester.getTopLeft( + find.byKey(const Key('filter-card-1003')), + ); + final newestSecond = tester.getTopLeft( + find.byKey(const Key('filter-card-1001')), + ); + expect(newestFirst.dx, lessThan(newestSecond.dx)); + }); + + testWidgets('should_open_player_when_filter_card_is_tapped', (tester) async { + await _pumpFilterPage(tester); + + expect(find.byKey(const Key('filter-badge-hot-1001')), findsOneWidget); + expect(find.byKey(const Key('filter-badge-hot-1003')), findsNothing); + final meta = tester.widget(find.byKey(const Key('filter-meta-1001'))); + expect(meta.data, contains('12')); + expect(meta.data, contains('300')); + expect(meta.data, contains('Romance')); + + await tester.tap(find.byKey(const Key('filter-card-1001'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('player-scaffold')), findsOneWidget); + }); + + testWidgets('should_show_empty_state_when_filters_match_nothing', ( + tester, + ) async { + await _pumpFilterPage(tester); + await tester.tap(find.byKey(const Key('filter-expand'))); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('filter-option-category-Romance'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('filter-option-language-en'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('filter-empty')), findsOneWidget); + }); +} + +Future _pumpFilterPage(WidgetTester tester) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final catalog = FakeCatalogRepository(dramas: _filterDramas); + await tester.pumpWidget( + LumaDramaApp( + playerFactory: createWidgetPlayer, + catalogRepository: catalog, + searchRepository: catalog, + categoryRepository: FakeCategoryRepository( + catalog: catalog, + categories: const [ + ContentCategory(id: '1', name: 'Romance'), + ContentCategory(id: '2', name: 'Action'), + ], + ), + watchHistoryRepository: InMemoryWatchHistoryRepository(), + favoriteRepository: InMemoryFavoriteRepository(cards: catalog.dramas), + playbackRepository: FakePlaybackRepository(), + commentRepository: FakeCommentRepository(), + interactionRepository: FakeInteractionRepository(), + haptics: FakeHaptics(), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('tab-theater'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('theater-toolbar-filter'))); + await tester.pumpAndSettle(); +} + +final List _filterDramas = [ + _drama( + id: '1001', + title: 'Alpha', + category: 'Romance', + genre: 'Suspense · Mystery', + language: 'id', + viewCount: 300, + isHot: true, + ), + _drama( + id: '1002', + title: 'Beta', + category: 'Action', + genre: 'Action · Thriller', + language: 'en', + viewCount: 500, + ), + _drama( + id: '1003', + title: 'Gamma', + category: 'Romance', + genre: 'Suspense', + language: 'zh-CN', + viewCount: 100, + ), +]; + +Drama _drama({ + required String id, + required String title, + required String category, + required String genre, + required String language, + required int viewCount, + bool isHot = false, +}) { + return Drama( + id: id, + title: title, + synopsis: '$title synopsis', + posterUrl: '', + category: category, + genre: genre, + totalEpisodes: 12, + viewCount: viewCount, + language: language, + isHot: isHot, + detailLoaded: true, + episodes: [ + Episode( + id: '$id-1', + number: 1, + title: 'Episode 1', + isFree: true, + videoUrl: 'https://example.test/$id.mp4', + ), + ], + ); +} diff --git a/test/discover/home_all_dramas_grid_test.dart b/test/discover/home_all_dramas_grid_test.dart index 4401ea1..77c34fd 100644 --- a/test/discover/home_all_dramas_grid_test.dart +++ b/test/discover/home_all_dramas_grid_test.dart @@ -51,25 +51,20 @@ void main() { expect(tester.takeException(), isNull); }); - testWidgets('should_open_filter_sheet_and_apply_category', (tester) async { + testWidgets('should_open_filter_page_from_theater_toolbar', (tester) async { await _pumpTheater(tester, categoryRepository: _GridCategoryRepository()); await tester.tap(find.byKey(const Key('theater-toolbar-filter'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('theater-filter-sheet')), findsOneWidget); - await tester.tap(find.byKey(const Key('theater-filter-option-7'))); + expect(find.byKey(const Key('filter-page')), findsOneWidget); + expect(find.byKey(const Key('filter-groups')), findsOneWidget); + + await tester.tap(find.byKey(const Key('filter-back'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('theater-filter-sheet')), findsNothing); - expect( - find.byKey(const Key('home-drama-card-city-lights-demo')), - findsOneWidget, - ); - expect( - find.byKey(const Key('home-drama-card-the-signal-demo')), - findsNothing, - ); + expect(find.byKey(const Key('filter-page')), findsNothing); + expect(find.byKey(const Key('discover-search')), findsOneWidget); }); testWidgets('should_filter_grid_when_category_chip_is_tapped', (