merge: 合并 K37-A 剧场筛选页
This commit is contained in:
commit
7250b5f0f9
12 files changed
+1012
-52
No files matched your search
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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人在追';
|
||||
|
||||
@@ -29,6 +29,17 @@
|
||||
"romance": "都市爱情",
|
||||
"actionGenre": "动作",
|
||||
"filter": "筛选",
|
||||
"filterGroupCategory": "全部体裁",
|
||||
"filterGroupTheme": "全部主题",
|
||||
"filterGroupLanguage": "全部语言",
|
||||
"filterGroupSort": "全部推荐",
|
||||
"sortNewest": "最新上架",
|
||||
"sortHottest": "最高热度",
|
||||
"expandFilters": "展开",
|
||||
"collapseFilters": "收起",
|
||||
"filterEmpty": "没有符合条件的短剧",
|
||||
"hotBadge": "爆剧",
|
||||
"filterAll": "全部",
|
||||
"seriesMeta": "系列剧 · 共{episodes}集 | 共{viewers}人在追",
|
||||
"watchFullSeries": "观看完整剧集 · 全{episodes}集",
|
||||
"feedTabAnime": "漫剧",
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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(' · ');
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -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', (
|
||||
|
||||
Reference in new issue
Block a user