feat(app): 剧场列表增加语种筛选

This commit is contained in:
Zhou Weidong committed 2026-10-20 04:35:48 +08:00
1 parent ade0cc3566
commit 30a5fa0776
5 files changed
+220 -4

No files matched your search

+27 -2
View File
@@ -36,6 +36,7 @@ class DiscoverLogic extends ChangeNotifier {
Duration searchDebounce = const Duration(milliseconds: 350);
static const int searchPageSize = 20;
List<Drama> searchResults = const [];
String? selectedLanguage;
int searchTotal = 0;
bool searchLoading = false;
String? searchError;
@@ -87,8 +88,32 @@ class DiscoverLogic extends ChangeNotifier {
/// 搜索态展示后端结果,非搜索态展示剧场自身的可见短剧。
List<Drama> visibleDramas(List<Drama> fallback) {
if (!hasSearchQuery) return fallback;
return searchResults;
if (!hasSearchQuery) return _filterByLanguage(fallback);
return _filterByLanguage(searchResults);
}
/// 当前 feed 中出现过的语种,按首次出现顺序去重。
List<String> languagesOf(List<Drama> dramas) {
final languages = <String>[];
for (final drama in dramas) {
final language = drama.language;
if (language.isNotEmpty && !languages.contains(language)) {
languages.add(language);
}
}
return languages;
}
/// 按语种筛选;null 表示全部。
void selectLanguage(String? code) {
selectedLanguage = code;
notify();
}
List<Drama> _filterByLanguage(List<Drama> dramas) {
final code = selectedLanguage;
if (code == null) return dramas;
return dramas.where((drama) => drama.language == code).toList();
}
Future<void> retrySearch() async {
+69 -1
View File
@@ -176,7 +176,12 @@ class _DiscoverPageState extends State<DiscoverPage> {
child: _categories(l10n, categories),
),
),
if (logic.resumeRecord != null && logic.resumeDrama(allDramas) != null)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(0, 0, 0, 12),
child: _languages(l10n, allDramas),
),
), if (logic.resumeRecord != null && logic.resumeDrama(allDramas) != null)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 14),
@@ -427,6 +432,69 @@ class _DiscoverPageState extends State<DiscoverPage> {
);
}
Widget _languages(AppLocalizations l10n, List<Drama> dramas) {
final languages = logic.languagesOf(dramas);
if (languages.isEmpty) return const SizedBox.shrink();
final selected = logic.selectedLanguage;
return SizedBox(
key: const Key('discover-language-chips'),
height: AppDimens.tapTarget,
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: 14),
scrollDirection: Axis.horizontal,
children: [
_languageChip(
key: const Key('discover-language-all'),
label: l10n.all,
selected: selected == null,
onTap: () => logic.selectLanguage(null),
),
for (final language in languages)
_languageChip(
key: Key('discover-language-$language'),
label: _languageLabel(l10n, language),
selected: selected == language,
onTap: () => logic.selectLanguage(language),
),
],
),
);
}
Widget _languageChip({
required Key key,
required String label,
required bool selected,
required VoidCallback onTap,
}) {
return Padding(
padding: const EdgeInsets.only(right: 8),
child: Center(
child: ChoiceChip(
key: key,
label: Text(label),
selected: selected,
showCheckmark: false,
onSelected: (_) => onTap(),
selectedColor: AppColors.blue,
backgroundColor: const Color(0xFF29272B),
side: BorderSide.none,
labelStyle: TextStyle(
color: selected ? AppColors.onPrimary : AppColors.muted,
fontSize: AppDimens.chipFont,
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
),
),
),
);
}
String _languageLabel(AppLocalizations l10n, String code) {
if (code == 'zh-CN' || code == 'zh') return l10n.languageChinese;
if (code == 'id') return l10n.languageIndonesian;
if (code == 'en') return l10n.languageEnglish;
return code;
}
Widget _continueCard(
AppLocalizations l10n,
Drama drama,
+2
View File
@@ -13,6 +13,7 @@ void main() {
'totalEpisodes': 77,
'isHot': true,
'viewCount': 3100000,
'language': 'zh-CN',
'preview': {
'id': 9,
'number': 1,
@@ -27,6 +28,7 @@ void main() {
expect(drama.episodes.single.videoUrl, '/files/episode/9.mp4');
expect(drama.episodeCount, 77);
expect(drama.isHot, isTrue);
expect(drama.language, 'zh-CN');
});
test('should_have_no_episodes_when_list_card_has_no_playable_preview', () {
@@ -0,0 +1,90 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.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 '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/models/home_feed.dart';
import 'package:fruitshorts/core/data/repositories/category_repository.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 '../support/fake_catalog.dart';
import '../support/fake_interaction_repositories.dart';
import '../support/fake_notice_repository.dart';
import '../support/widget_player.dart';
void main() {
setUp(() {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.empty();
});
testWidgets('should_filter_theater_grid_when_language_chip_is_tapped', (
tester,
) async {
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
LumaDramaApp(
playerFactory: createWidgetPlayer,
catalogRepository: FakeCatalogRepository(),
categoryRepository: _MixedLanguageCategoryRepository(),
watchHistoryRepository: InMemoryWatchHistoryRepository(),
favoriteRepository: InMemoryFavoriteRepository(),
playbackRepository: FakePlaybackRepository(),
commentRepository: FakeCommentRepository(),
interactionRepository: FakeInteractionRepository(),
noticeRepository: FakeNoticeRepository(),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('tab-discover')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('home-drama-card-zh-demo')), findsOneWidget);
expect(find.byKey(const Key('home-drama-card-id-demo')), findsOneWidget);
await tester.tap(find.byKey(const Key('discover-language-zh-CN')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('home-drama-card-zh-demo')), findsOneWidget);
expect(find.byKey(const Key('home-drama-card-id-demo')), findsNothing);
});
}
class _MixedLanguageCategoryRepository implements CategoryRepository {
@override
Future<List<ContentCategory>> fetchCategories() async => const [];
@override
Future<HomeFeed> fetchDiscoverFeed() async => HomeFeed(
sections: [
FeedSection(
code: 'hot',
title: 'Hot',
dramas: [
_drama('zh-demo', 'Chinese Demo', 'zh-CN'),
_drama('id-demo', 'Indonesian Demo', 'id'),
],
),
],
);
}
Drama _drama(String id, String title, String language) => Drama(
id: id,
title: title,
synopsis: '$title synopsis',
posterUrl: '/files/$id.png',
language: language,
detailLoaded: true,
);
+32 -1
View File
@@ -8,7 +8,8 @@ import 'package:fruitshorts/pages/discover/discover_logic.dart';
import '../support/fake_watch_history.dart';
Drama _drama(String id, {List<int> numbers = const [1, 2, 3]}) => Drama(
Drama _drama(String id, {List<int> numbers = const [1, 2, 3], String language = ''}) => Drama(
language: language,
id: id,
title: id,
synopsis: 'synopsis',
@@ -64,6 +65,36 @@ void main() {
logic.dispose();
});
test('should_filter_visible_dramas_when_language_is_selected', () {
final logic = DiscoverLogic(history: FakeWatchHistory(), drama: _drama('s'));
final dramas = [
_drama('zh', language: 'zh-CN'),
_drama('id', language: 'id'),
_drama('unknown'),
];
expect(logic.visibleDramas(dramas), hasLength(3));
logic.selectLanguage('id');
expect(logic.visibleDramas(dramas).map((item) => item.id), ['id']);
logic.selectLanguage(null);
expect(logic.visibleDramas(dramas), hasLength(3));
logic.dispose();
});
test('should_list_distinct_languages_when_feed_has_cards', () {
final logic = DiscoverLogic(history: FakeWatchHistory(), drama: _drama('s'));
final dramas = [
_drama('zh', language: 'zh-CN'),
_drama('id', language: 'id'),
_drama('id2', language: 'id'),
_drama('unknown'),
];
expect(logic.languagesOf(dramas), ['zh-CN', 'id']);
logic.dispose();
});
test('should_have_no_resume_position_when_server_has_no_record', () async {
final history = FakeWatchHistory();
final logic = DiscoverLogic(