diff --git a/lib/pages/discover/discover_logic.dart b/lib/pages/discover/discover_logic.dart index aefb818..ee27518 100644 --- a/lib/pages/discover/discover_logic.dart +++ b/lib/pages/discover/discover_logic.dart @@ -36,6 +36,7 @@ class DiscoverLogic extends ChangeNotifier { Duration searchDebounce = const Duration(milliseconds: 350); static const int searchPageSize = 20; List searchResults = const []; + String? selectedLanguage; int searchTotal = 0; bool searchLoading = false; String? searchError; @@ -87,8 +88,32 @@ class DiscoverLogic extends ChangeNotifier { /// 搜索态展示后端结果,非搜索态展示剧场自身的可见短剧。 List visibleDramas(List fallback) { - if (!hasSearchQuery) return fallback; - return searchResults; + if (!hasSearchQuery) return _filterByLanguage(fallback); + return _filterByLanguage(searchResults); + } + + /// 当前 feed 中出现过的语种,按首次出现顺序去重。 + List languagesOf(List dramas) { + final languages = []; + 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 _filterByLanguage(List dramas) { + final code = selectedLanguage; + if (code == null) return dramas; + return dramas.where((drama) => drama.language == code).toList(); } Future retrySearch() async { diff --git a/lib/pages/discover/discover_view.dart b/lib/pages/discover/discover_view.dart index d33fabc..7d8e370 100644 --- a/lib/pages/discover/discover_view.dart +++ b/lib/pages/discover/discover_view.dart @@ -176,7 +176,12 @@ class _DiscoverPageState extends State { 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 { ); } + Widget _languages(AppLocalizations l10n, List 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, diff --git a/test/catalog/drama_model_test.dart b/test/catalog/drama_model_test.dart index 7b715cb..bb99b81 100644 --- a/test/catalog/drama_model_test.dart +++ b/test/catalog/drama_model_test.dart @@ -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', () { diff --git a/test/discover/discover_language_filter_test.dart b/test/discover/discover_language_filter_test.dart new file mode 100644 index 0000000..db69362 --- /dev/null +++ b/test/discover/discover_language_filter_test.dart @@ -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> fetchCategories() async => const []; + + @override + Future 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, +); diff --git a/test/discover/discover_logic_test.dart b/test/discover/discover_logic_test.dart index 55fc790..507dfe6 100644 --- a/test/discover/discover_logic_test.dart +++ b/test/discover/discover_logic_test.dart @@ -8,7 +8,8 @@ import 'package:fruitshorts/pages/discover/discover_logic.dart'; import '../support/fake_watch_history.dart'; -Drama _drama(String id, {List numbers = const [1, 2, 3]}) => Drama( +Drama _drama(String id, {List 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(