merge: 合并 K37-A 剧场筛选页

This commit is contained in:
Zhou Weidong committed 2026-10-20 15:39:53 +08:00
commit 7250b5f0f9
12 files changed
+1012 -52

No files matched your search

+11
View File
@@ -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": {
+11
View File
@@ -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",
+66
View File
@@ -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:
+33
View File
@@ -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';
+33
View File
@@ -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';
+33
View File
@@ -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人在追';
+11
View File
@@ -29,6 +29,17 @@
"romance": "都市爱情",
"actionGenre": "动作",
"filter": "筛选",
"filterGroupCategory": "全部体裁",
"filterGroupTheme": "全部主题",
"filterGroupLanguage": "全部语言",
"filterGroupSort": "全部推荐",
"sortNewest": "最新上架",
"sortHottest": "最高热度",
"expandFilters": "展开",
"collapseFilters": "收起",
"filterEmpty": "没有符合条件的短剧",
"hotBadge": "爆剧",
"filterAll": "全部",
"seriesMeta": "系列剧 · 共{episodes}集 | 共{viewers}人在追",
"watchFullSeries": "观看完整剧集 · 全{episodes}集",
"feedTabAnime": "漫剧",
+4 -40
View File
@@ -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<DiscoverPage> {
? 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<DiscoverPage> {
);
}
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<DiscoverPage> {
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<DiscoverPage> {
);
}
Future<void> _openFilterSheet(
AppLocalizations l10n,
CategoryController categories,
) async {
await showModalBottomSheet<void>(
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<void>(MaterialPageRoute(builder: (_) => page));
}
+463
View File
@@ -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<FilterPage> createState() => _FilterPageState();
}
class _FilterPageState extends State<FilterPage> {
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<CategoryController>();
final catalog = context.watch<CatalogController>();
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<SystemUiOverlayStyle>(
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<String> categories,
List<String> themes,
List<String> 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<String> values,
required String selected,
required ValueChanged<String> 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<Drama> _filtered(List<Drama> 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<String, String> _languageNames = {
'zh': '简体中文',
'zh-cn': '简体中文',
'zh-hans': '简体中文',
'en': 'English',
'en-us': 'English',
'id': 'Bahasa Indonesia',
'id-id': 'Bahasa Indonesia',
};
List<String> _themeValues(Drama drama) {
final values = <String>[];
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<String> _uniqueValues(Iterable<String> source) {
final result = <String>[];
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);
}
@@ -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(' · ');
}
}
+218
View File
@@ -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<Text>(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<void> _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<Drama> _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',
),
],
);
}
+7 -12
View File
@@ -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', (