From ade0cc3566c2a57bcc143e1fdbf346dbab0fc542 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 04:34:12 +0800 Subject: [PATCH] =?UTF-8?q?feat(app):=20=E6=90=9C=E7=B4=A2=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E8=B0=83=E7=94=A8=E5=90=8E=E7=AB=AF=E5=B9=B6=E6=B8=B2?= =?UTF-8?q?=E6=9F=93=E5=8D=A1=E7=89=87=E7=BB=93=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/app.dart | 7 ++ lib/core/data/models/drama.dart | 6 + lib/core/data/models/search_result.dart | 22 ++++ lib/core/data/remote/catalog_api.dart | 18 +++ .../data/repositories/search_repository.dart | 26 ++++ lib/pages/discover/discover_binding.dart | 10 +- lib/pages/discover/discover_logic.dart | 118 +++++++++++++++--- lib/pages/discover/discover_view.dart | 19 ++- .../discover/widgets/home_drama_grid.dart | 29 +++++ test/catalog/search_api_test.dart | 51 ++++++++ test/discover/discover_search_test.dart | 116 +++++++++++++++++ test/discover/home_all_dramas_grid_test.dart | 1 + test/support/fake_catalog.dart | 21 +++- 13 files changed, 424 insertions(+), 20 deletions(-) create mode 100644 lib/core/data/models/search_result.dart create mode 100644 lib/core/data/repositories/search_repository.dart create mode 100644 test/catalog/search_api_test.dart create mode 100644 test/discover/discover_search_test.dart diff --git a/lib/app.dart b/lib/app.dart index a8c35ea..8ddc4ee 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -30,6 +30,7 @@ import 'core/data/repositories/favorite_repository.dart'; import 'core/data/repositories/interaction_repository.dart'; import 'core/data/repositories/notice_repository.dart'; import 'core/data/repositories/playback_repository.dart'; +import 'core/data/repositories/search_repository.dart'; import 'core/data/repositories/store_repository.dart'; import 'core/data/repositories/watch_history_repository.dart'; import 'core/data/services/auth_controller.dart'; @@ -64,6 +65,7 @@ class LumaDramaApp extends StatefulWidget { this.accountRepository, this.commerceRepository, this.storeRepository, + this.searchRepository, this.haptics = const FlutterHaptics(), }); @@ -82,6 +84,7 @@ class LumaDramaApp extends StatefulWidget { final AccountRepository? accountRepository; final CommerceRepository? commerceRepository; final StoreRepository? storeRepository; + final SearchRepository? searchRepository; final Haptics haptics; @override @@ -149,6 +152,9 @@ class _LumaDramaAppState extends State { _commerceRepository, ); + late final SearchRepository _searchRepository = + widget.searchRepository ?? HttpSearchRepository(CatalogApi(_apiClient)); + late final StoreRepository _storeRepository = widget.storeRepository ?? HttpStoreRepository(CommerceApi(_apiClient)); @@ -223,6 +229,7 @@ class _LumaDramaAppState extends State { Provider.value(value: _accountRepository), Provider.value(value: _commerceRepository), Provider.value(value: _storeRepository), + Provider.value(value: _searchRepository), Provider.value(value: widget.haptics), ChangeNotifierProvider( create: (_) => CatalogController(_catalogRepository)..load(), diff --git a/lib/core/data/models/drama.dart b/lib/core/data/models/drama.dart index fed6cfa..d1b41c3 100644 --- a/lib/core/data/models/drama.dart +++ b/lib/core/data/models/drama.dart @@ -65,6 +65,7 @@ class Drama { this.freeEpisodes = 0, this.isHot = false, this.viewCount = 0, + this.language = '', this.detailLoaded = false, }); @@ -80,6 +81,9 @@ class Drama { final bool isHot; final int viewCount; + /// 卡片语种(如 zh-CN / id),用于剧场语言筛选。 + final String language; + /// 详情接口是否已经取过完整剧集列表。 /// 列表卡片只带一集可播预览,需要完整剧集时再进详情。 final bool detailLoaded; @@ -104,6 +108,7 @@ class Drama { isHot: json['isHot'] == true, // viewCount 后端是 Long,按字符串下发(见 json_numbers.dart) viewCount: jsonToInt(json['viewCount']), + language: json['language'] as String? ?? '', ); } @@ -120,6 +125,7 @@ class Drama { freeEpisodes: (json['freeEpisodes'] as num?)?.toInt() ?? 0, isHot: json['isHot'] == true, viewCount: jsonToInt(json['viewCount']), + language: json['language'] as String? ?? '', detailLoaded: true, episodes: (json['episodes'] as List? ?? const []) .whereType>() diff --git a/lib/core/data/models/search_result.dart b/lib/core/data/models/search_result.dart new file mode 100644 index 0000000..f33781d --- /dev/null +++ b/lib/core/data/models/search_result.dart @@ -0,0 +1,22 @@ +// Author: fruit.com.cn + +import 'drama.dart'; +import 'json_numbers.dart'; + +/// 搜索分页结果:`GET /app/search` 的 `{total, list}`。 +class SearchResult { + const SearchResult({this.total = 0, this.list = const []}); + + final int total; + final List list; + + factory SearchResult.fromJson(Map json) { + return SearchResult( + total: jsonToInt(json['total']), + list: (json['list'] as List? ?? const []) + .whereType>() + .map(Drama.fromCardJson) + .toList(), + ); + } +} diff --git a/lib/core/data/remote/catalog_api.dart b/lib/core/data/remote/catalog_api.dart index 4346460..666c56f 100644 --- a/lib/core/data/remote/catalog_api.dart +++ b/lib/core/data/remote/catalog_api.dart @@ -1,5 +1,6 @@ import '../models/drama.dart'; import '../models/home_feed.dart'; +import '../models/search_result.dart'; import 'api_client.dart'; /// 客户端目录接口,对应后端 `/app/**`。 @@ -16,6 +17,23 @@ class CatalogApi { return HomeFeed.fromJson(_asMap(await _client.get('/app/discover/feed'))); } + /// 搜索已上线短剧:按标题/简介模糊匹配,分页返回卡片列表。 + Future search( + String keyword, { + int pageIndex = 1, + int pageSize = 20, + }) async { + final data = await _client.get( + '/app/search', + query: { + 'keyword': keyword, + 'pageIndex': '$pageIndex', + 'pageSize': '$pageSize', + }, + ); + return SearchResult.fromJson(_asMap(data)); + } + Future dramaDetail(String dramaId) async { return Drama.fromDetailJson( _asMap(await _client.get('/app/dramas/$dramaId')), diff --git a/lib/core/data/repositories/search_repository.dart b/lib/core/data/repositories/search_repository.dart new file mode 100644 index 0000000..9380011 --- /dev/null +++ b/lib/core/data/repositories/search_repository.dart @@ -0,0 +1,26 @@ +// Author: fruit.com.cn + +import '../models/search_result.dart'; +import '../remote/catalog_api.dart'; + +/// 搜索数据来源:只调用后端 `/app/search`,不在客户端做本地过滤。 +abstract interface class SearchRepository { + Future search( + String keyword, { + int pageIndex = 1, + int pageSize = 20, + }); +} + +class HttpSearchRepository implements SearchRepository { + const HttpSearchRepository(this._api); + + final CatalogApi _api; + + @override + Future search( + String keyword, { + int pageIndex = 1, + int pageSize = 20, + }) => _api.search(keyword, pageIndex: pageIndex, pageSize: pageSize); +} diff --git a/lib/pages/discover/discover_binding.dart b/lib/pages/discover/discover_binding.dart index 89024ce..c85dbbe 100644 --- a/lib/pages/discover/discover_binding.dart +++ b/lib/pages/discover/discover_binding.dart @@ -1,6 +1,7 @@ -// Author: fruit.com.cn +// Author: fruit.com.cn import '../../core/data/models/drama.dart'; +import '../../core/data/repositories/search_repository.dart'; import '../../core/data/repositories/watch_history_repository.dart'; import 'discover_logic.dart'; @@ -8,5 +9,10 @@ class DiscoverBinding { DiscoverLogic create({ required Drama drama, required WatchHistoryRepository history, - }) => DiscoverLogic(drama: drama, history: history); + SearchRepository? search, + }) => DiscoverLogic( + drama: drama, + history: history, + searchRepository: search, + ); } diff --git a/lib/pages/discover/discover_logic.dart b/lib/pages/discover/discover_logic.dart index bff295a..aefb818 100644 --- a/lib/pages/discover/discover_logic.dart +++ b/lib/pages/discover/discover_logic.dart @@ -1,20 +1,29 @@ // Author: fruit.com.cn +import 'dart:async'; + import 'package:flutter/material.dart'; import '../../core/data/models/drama.dart'; import '../../core/data/models/watch_progress.dart'; +import '../../core/data/remote/api_exception.dart'; +import '../../core/data/repositories/search_repository.dart'; import '../../core/data/repositories/watch_history_repository.dart'; import '../../l10n/app_localizations.dart'; enum DiscoverPageAction { home, my, watchlist, detail, resume } class DiscoverLogic extends ChangeNotifier { - DiscoverLogic({required this.history, required this.drama}) { + DiscoverLogic({ + required this.history, + required this.drama, + this.searchRepository, + }) { refreshPosition(); } final WatchHistoryRepository history; + final SearchRepository? searchRepository; Drama drama; late Future position; WatchProgress? resumeRecord; @@ -22,6 +31,17 @@ class DiscoverLogic extends ChangeNotifier { final FocusNode searchFocus = FocusNode(); final ScrollController scroll = ScrollController(); bool searchOpen = false; + + /// 搜索防抖与分页口径:与后端 `/app/search` 一致。 + Duration searchDebounce = const Duration(milliseconds: 350); + static const int searchPageSize = 20; + List searchResults = const []; + int searchTotal = 0; + bool searchLoading = false; + String? searchError; + Timer? _searchTimer; + String _lastRequestedQuery = ''; + int _searchRequest = 0; bool _disposed = false; void notify() { @@ -51,20 +71,89 @@ class DiscoverLogic extends ChangeNotifier { notify(); } - void onSearchChanged() => notify(); + bool get hasSearchQuery => search.text.trim().isNotEmpty; - List filtered(List dramas) { - final query = search.text.trim().toLowerCase(); - if (query.isEmpty) return dramas; - return dramas.where((drama) { - final haystack = [ - drama.title, - drama.genre, - drama.category, - drama.synopsis, - ].join(' ').toLowerCase(); - return haystack.contains(query); - }).toList(); + /// 输入变化只触发后端查询;本地不再做标题/简介匹配。 + void onSearchChanged() { + final query = search.text.trim(); + if (query.isEmpty) { + _resetSearch(); + notify(); + return; + } + notify(); + _scheduleSearch(query); + } + + /// 搜索态展示后端结果,非搜索态展示剧场自身的可见短剧。 + List visibleDramas(List fallback) { + if (!hasSearchQuery) return fallback; + return searchResults; + } + + Future retrySearch() async { + final query = search.text.trim(); + if (query.isEmpty) return; + await _runSearch(query); + } + + void _resetSearch() { + _searchTimer?.cancel(); + _searchRequest++; + searchResults = const []; + searchTotal = 0; + searchLoading = false; + searchError = null; + _lastRequestedQuery = ''; + } + + void _scheduleSearch(String query) { + final alreadyRequested = query == _lastRequestedQuery; + if (alreadyRequested && (searchLoading || searchResults.isNotEmpty || searchError != null)) { + return; + } + _lastRequestedQuery = query; + _searchTimer?.cancel(); + if (searchDebounce == Duration.zero) { + unawaited(_runSearch(query)); + return; + } + _searchTimer = Timer(searchDebounce, () => unawaited(_runSearch(query))); + } + + Future _runSearch(String query) async { + final repository = searchRepository; + if (repository == null) return; + final request = ++_searchRequest; + searchLoading = true; + searchError = null; + notify(); + try { + final result = await repository.search( + query, + pageIndex: 1, + pageSize: searchPageSize, + ); + if (request != _searchRequest) return; + searchResults = result.list; + searchTotal = result.total; + } on ApiException catch (exception) { + _applySearchError(request, exception.message); + } catch (_) { + _applySearchError(request, 'searchFailed'); + } finally { + if (request == _searchRequest) { + searchLoading = false; + notify(); + } + } + } + + void _applySearchError(int request, String message) { + if (request != _searchRequest) return; + searchError = message; + searchResults = const []; + searchTotal = 0; } Drama? resumeDrama(List dramas) { @@ -165,6 +254,7 @@ class DiscoverLogic extends ChangeNotifier { @override void dispose() { _disposed = true; + _searchTimer?.cancel(); search.dispose(); searchFocus.dispose(); scroll.dispose(); diff --git a/lib/pages/discover/discover_view.dart b/lib/pages/discover/discover_view.dart index 059cd19..d33fabc 100644 --- a/lib/pages/discover/discover_view.dart +++ b/lib/pages/discover/discover_view.dart @@ -8,8 +8,10 @@ import '../../core/config/app_config.dart'; import '../../core/data/models/drama.dart'; import '../../core/data/models/home_feed.dart'; import '../../core/data/models/watch_progress.dart'; +import '../../core/data/repositories/search_repository.dart'; import '../../core/data/repositories/watch_history_repository.dart'; import '../../core/data/services/category_controller.dart'; +import '../../core/formatting/error_text.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_dimens.dart'; import '../../core/theme/app_theme.dart'; @@ -30,6 +32,7 @@ class DiscoverPage extends StatefulWidget { required this.duration, this.focusSearch = false, this.history, + this.searchRepository, this.embedded = false, this.active = true, this.searchRequest = 0, @@ -48,6 +51,7 @@ class DiscoverPage extends StatefulWidget { final Duration duration; final bool focusSearch; final WatchHistoryRepository? history; + final SearchRepository? searchRepository; final bool embedded; final bool active; final int searchRequest; @@ -74,6 +78,7 @@ class _DiscoverPageState extends State { logic = DiscoverBinding().create( drama: widget.drama, history: widget.history ?? context.read(), + search: widget.searchRepository ?? context.read(), ); if (widget.focusSearch) { logic.searchOpen = true; @@ -115,7 +120,7 @@ class _DiscoverPageState extends State { final l10n = AppLocalizations.of(context)!; final categories = context.watch(); final allDramas = [...?widget.dramas, ...categories.visibleDramas]; - final visible = logic.filtered(categories.visibleDramas); + final visible = logic.visibleDramas(categories.visibleDramas); final content = SafeArea( bottom: !widget.embedded, child: Column( @@ -235,7 +240,10 @@ class _DiscoverPageState extends State { l10n: l10n, categories: categories, dramas: visible, - searching: logic.search.text.trim().isNotEmpty, + searching: logic.hasSearchQuery, + searchLoading: logic.searchLoading, + searchError: logic.searchError, + onRetrySearch: () => logic.retrySearch(), onOpen: (drama) => logic.openCard( context, drama, @@ -329,6 +337,7 @@ class _DiscoverPageState extends State { controller: logic.search, focusNode: logic.searchFocus, onChanged: (_) => logic.onSearchChanged(), + onTapOutside: (_) => logic.searchFocus.unfocus(), textInputAction: TextInputAction.search, style: const TextStyle(color: AppColors.onSurface, fontSize: 13), @@ -343,6 +352,12 @@ class _DiscoverPageState extends State { color: AppColors.subtle, size: 17, ), + suffixIcon: logic.searchLoading + ? const Padding( + padding: EdgeInsets.all(11), + child: CircularProgressIndicator(strokeWidth: 2), + ) + : null, contentPadding: const EdgeInsets.symmetric(horizontal: 12), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(20), diff --git a/lib/pages/discover/widgets/home_drama_grid.dart b/lib/pages/discover/widgets/home_drama_grid.dart index d2bd04c..44ec014 100644 --- a/lib/pages/discover/widgets/home_drama_grid.dart +++ b/lib/pages/discover/widgets/home_drama_grid.dart @@ -8,6 +8,9 @@ class _HomeDramaGrid extends StatelessWidget { required this.categories, required this.dramas, required this.searching, + required this.searchLoading, + required this.searchError, + required this.onRetrySearch, required this.onOpen, }); @@ -15,6 +18,9 @@ class _HomeDramaGrid extends StatelessWidget { final CategoryController categories; final List dramas; final bool searching; + final bool searchLoading; + final String? searchError; + final VoidCallback onRetrySearch; final ValueChanged onOpen; @override @@ -37,6 +43,29 @@ class _HomeDramaGrid extends StatelessWidget { ), ); } + if (searching && searchLoading) { + return const SliverFillRemaining( + hasScrollBody: false, + child: Center( + child: CircularProgressIndicator( + key: Key('discover-search-loading'), + strokeWidth: 2, + ), + ), + ); + } + if (searching && searchError != null) { + return _fill( + EmptyState( + key: const Key('discover-search-error'), + icon: Icons.cloud_off_rounded, + title: errorText(l10n, searchError, l10n.searchFailed), + actionLabel: l10n.retry, + actionKey: const Key('discover-search-retry'), + onAction: onRetrySearch, + ), + ); + } if (dramas.isEmpty) { return _fill( EmptyState( diff --git a/test/catalog/search_api_test.dart b/test/catalog/search_api_test.dart new file mode 100644 index 0000000..7e54257 --- /dev/null +++ b/test/catalog/search_api_test.dart @@ -0,0 +1,51 @@ +// Author: fruit.com.cn + +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/remote/api_client.dart'; +import 'package:fruitshorts/core/data/remote/catalog_api.dart'; + +import '../support/fake_http_adapter.dart'; + +void main() { + test('should_request_search_with_keyword_and_paging_when_searching', () async { + final adapter = FakeHttpAdapter( + (options) async => FakeHttpResponse( + jsonEncode({ + 'code': 0, + 'message': 'ok', + 'data': { + 'total': 1, + 'list': [ + { + 'id': '2111012753262120962', + 'title': 'Velvet Nights', + 'synopsis': 'A city romance', + 'cover': '/files/velvet.png', + 'genre': 'Romance', + 'totalEpisodes': 12, + 'isHot': true, + 'language': 'id', + }, + ], + }, + }), + ), + ); + final api = CatalogApi( + ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter), + ); + + final page = await api.search('velvet'); + + final request = adapter.requests.single; + expect(request.uri.path, '/app/search'); + expect(request.queryParameters['keyword'], 'velvet'); + expect(request.queryParameters['pageIndex'], '1'); + expect(request.queryParameters['pageSize'], '20'); + expect(page.total, 1); + expect(page.list.single.title, 'Velvet Nights'); + expect(page.list.single.language, 'id'); + }); +} diff --git a/test/discover/discover_search_test.dart b/test/discover/discover_search_test.dart new file mode 100644 index 0000000..92a45da --- /dev/null +++ b/test/discover/discover_search_test.dart @@ -0,0 +1,116 @@ +// Author: fruit.com.cn + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/models/search_result.dart'; +import 'package:fruitshorts/core/data/remote/api_exception.dart'; +import 'package:fruitshorts/core/data/repositories/search_repository.dart'; +import 'package:fruitshorts/pages/discover/discover_logic.dart'; + +import '../support/fake_watch_history.dart'; + +Drama _drama(String id, {String language = 'id'}) => Drama( + id: id, + title: id, + synopsis: 'synopsis', + posterUrl: '/files/$id.png', + language: language, + episodes: const [], +); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('should_query_backend_and_render_results_when_search_text_changes', () async { + final repository = _FakeSearchRepository(results: [_drama('velvet')]); + final logic = DiscoverLogic( + history: FakeWatchHistory(), + drama: _drama('signal'), + searchRepository: repository, + )..searchDebounce = Duration.zero; + + logic.search.text = 'velvet'; + logic.onSearchChanged(); + await pumpEventQueue(); + + expect(repository.calls, [(keyword: 'velvet', pageIndex: 1, pageSize: 20)]); + expect(logic.searchLoading, isFalse); + expect(logic.visibleDramas(const []).single.id, 'velvet'); + logic.dispose(); + }); + + test('should_keep_server_results_when_text_only_loses_whitespace', () async { + final repository = _FakeSearchRepository(results: [_drama('velvet')]); + final logic = DiscoverLogic( + history: FakeWatchHistory(), + drama: _drama('signal'), + searchRepository: repository, + )..searchDebounce = Duration.zero; + + logic.search.text = 'velvet '; + logic.onSearchChanged(); + await pumpEventQueue(); + logic.search.text = 'velvet'; + logic.onSearchChanged(); + await pumpEventQueue(); + + expect(repository.calls, hasLength(1)); + expect(logic.visibleDramas(const []).single.id, 'velvet'); + logic.dispose(); + }); + + test('should_expose_error_message_when_search_fails', () async { + final logic = DiscoverLogic( + history: FakeWatchHistory(), + drama: _drama('signal'), + searchRepository: _FakeSearchRepository( + error: const ApiException(400, 'Keyword is invalid'), + ), + )..searchDebounce = Duration.zero; + + logic.search.text = '%%%'; + logic.onSearchChanged(); + await pumpEventQueue(); + + expect(logic.searchError, 'Keyword is invalid'); + expect(logic.searchLoading, isFalse); + logic.dispose(); + }); + + test('should_clear_server_results_when_search_text_is_empty', () async { + final repository = _FakeSearchRepository(results: [_drama('velvet')]); + final logic = DiscoverLogic( + history: FakeWatchHistory(), + drama: _drama('signal'), + searchRepository: repository, + )..searchDebounce = Duration.zero; + + logic.search.text = 'velvet'; + logic.onSearchChanged(); + await pumpEventQueue(); + logic.search.clear(); + logic.onSearchChanged(); + + expect(logic.visibleDramas([_drama('local')]).single.id, 'local'); + logic.dispose(); + }); +} + +class _FakeSearchRepository implements SearchRepository { + _FakeSearchRepository({this.results = const [], this.error}); + + final List results; + final ApiException? error; + final List<({String keyword, int pageIndex, int pageSize})> calls = []; + + @override + Future search( + String keyword, { + int pageIndex = 1, + int pageSize = 20, + }) async { + calls.add((keyword: keyword, pageIndex: pageIndex, pageSize: pageSize)); + if (error != null) throw error!; + return SearchResult(total: results.length, list: results); + } +} diff --git a/test/discover/home_all_dramas_grid_test.dart b/test/discover/home_all_dramas_grid_test.dart index de51edf..e515fc5 100644 --- a/test/discover/home_all_dramas_grid_test.dart +++ b/test/discover/home_all_dramas_grid_test.dart @@ -428,6 +428,7 @@ Future _pumpApp( LumaDramaApp( playerFactory: createWidgetPlayer, catalogRepository: catalog, + searchRepository: catalog, categoryRepository: categoryRepository, watchHistoryRepository: history ?? InMemoryWatchHistoryRepository(), favoriteRepository: InMemoryFavoriteRepository(), diff --git a/test/support/fake_catalog.dart b/test/support/fake_catalog.dart index 7c41eed..9cb85f4 100644 --- a/test/support/fake_catalog.dart +++ b/test/support/fake_catalog.dart @@ -1,9 +1,11 @@ -import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/models/drama.dart'; import 'package:fruitshorts/core/data/models/home_feed.dart'; +import 'package:fruitshorts/core/data/models/search_result.dart'; import 'package:fruitshorts/core/data/repositories/catalog_repository.dart'; +import 'package:fruitshorts/core/data/repositories/search_repository.dart'; /// 测试用目录数据:三部分区里的短剧,标题与后端演示数据保持一致。 -class FakeCatalogRepository implements CatalogRepository { +class FakeCatalogRepository implements CatalogRepository, SearchRepository { FakeCatalogRepository({List? dramas, this.banners = const []}) : dramas = dramas ?? demoDramas; @@ -21,6 +23,20 @@ class FakeCatalogRepository implements CatalogRepository { return dramas.firstWhere((drama) => drama.id == dramaId); } + @override + Future search( + String keyword, { + int pageIndex = 1, + int pageSize = 20, + }) async { + final query = keyword.trim().toLowerCase(); + final hits = dramas.where((drama) { + final haystack = [drama.title, drama.synopsis, drama.genre].join(' '); + return haystack.toLowerCase().contains(query); + }).toList(); + return SearchResult(total: hits.length, list: hits); + } + HomeFeed _feed() => HomeFeed( banners: banners, sections: [FeedSection(code: 'hot', title: '热门推荐', dramas: dramas)], @@ -45,6 +61,7 @@ Drama _drama(String id, String title, String genre, {required int format}) => synopsis: '$title synopsis', posterUrl: '/files/$id.png', genre: genre, + language: 'id', detailLoaded: true, episodes: [ for (var index = 0; index < 7; index++)