feat(app): 搜索改为调用后端并渲染卡片结果
This commit is contained in:
1 parent
730e38622f
commit
ade0cc3566
13 files changed
+424
-20
No files matched your search
@@ -30,6 +30,7 @@ import 'core/data/repositories/favorite_repository.dart';
|
|||||||
import 'core/data/repositories/interaction_repository.dart';
|
import 'core/data/repositories/interaction_repository.dart';
|
||||||
import 'core/data/repositories/notice_repository.dart';
|
import 'core/data/repositories/notice_repository.dart';
|
||||||
import 'core/data/repositories/playback_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/store_repository.dart';
|
||||||
import 'core/data/repositories/watch_history_repository.dart';
|
import 'core/data/repositories/watch_history_repository.dart';
|
||||||
import 'core/data/services/auth_controller.dart';
|
import 'core/data/services/auth_controller.dart';
|
||||||
@@ -64,6 +65,7 @@ class LumaDramaApp extends StatefulWidget {
|
|||||||
this.accountRepository,
|
this.accountRepository,
|
||||||
this.commerceRepository,
|
this.commerceRepository,
|
||||||
this.storeRepository,
|
this.storeRepository,
|
||||||
|
this.searchRepository,
|
||||||
this.haptics = const FlutterHaptics(),
|
this.haptics = const FlutterHaptics(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -82,6 +84,7 @@ class LumaDramaApp extends StatefulWidget {
|
|||||||
final AccountRepository? accountRepository;
|
final AccountRepository? accountRepository;
|
||||||
final CommerceRepository? commerceRepository;
|
final CommerceRepository? commerceRepository;
|
||||||
final StoreRepository? storeRepository;
|
final StoreRepository? storeRepository;
|
||||||
|
final SearchRepository? searchRepository;
|
||||||
final Haptics haptics;
|
final Haptics haptics;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -149,6 +152,9 @@ class _LumaDramaAppState extends State<LumaDramaApp> {
|
|||||||
_commerceRepository,
|
_commerceRepository,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
late final SearchRepository _searchRepository =
|
||||||
|
widget.searchRepository ?? HttpSearchRepository(CatalogApi(_apiClient));
|
||||||
|
|
||||||
late final StoreRepository _storeRepository =
|
late final StoreRepository _storeRepository =
|
||||||
widget.storeRepository ?? HttpStoreRepository(CommerceApi(_apiClient));
|
widget.storeRepository ?? HttpStoreRepository(CommerceApi(_apiClient));
|
||||||
|
|
||||||
@@ -223,6 +229,7 @@ class _LumaDramaAppState extends State<LumaDramaApp> {
|
|||||||
Provider<AccountRepository>.value(value: _accountRepository),
|
Provider<AccountRepository>.value(value: _accountRepository),
|
||||||
Provider<CommerceRepository>.value(value: _commerceRepository),
|
Provider<CommerceRepository>.value(value: _commerceRepository),
|
||||||
Provider<StoreRepository>.value(value: _storeRepository),
|
Provider<StoreRepository>.value(value: _storeRepository),
|
||||||
|
Provider<SearchRepository>.value(value: _searchRepository),
|
||||||
Provider<Haptics>.value(value: widget.haptics),
|
Provider<Haptics>.value(value: widget.haptics),
|
||||||
ChangeNotifierProvider(
|
ChangeNotifierProvider(
|
||||||
create: (_) => CatalogController(_catalogRepository)..load(),
|
create: (_) => CatalogController(_catalogRepository)..load(),
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ class Drama {
|
|||||||
this.freeEpisodes = 0,
|
this.freeEpisodes = 0,
|
||||||
this.isHot = false,
|
this.isHot = false,
|
||||||
this.viewCount = 0,
|
this.viewCount = 0,
|
||||||
|
this.language = '',
|
||||||
this.detailLoaded = false,
|
this.detailLoaded = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -80,6 +81,9 @@ class Drama {
|
|||||||
final bool isHot;
|
final bool isHot;
|
||||||
final int viewCount;
|
final int viewCount;
|
||||||
|
|
||||||
|
/// 卡片语种(如 zh-CN / id),用于剧场语言筛选。
|
||||||
|
final String language;
|
||||||
|
|
||||||
/// 详情接口是否已经取过完整剧集列表。
|
/// 详情接口是否已经取过完整剧集列表。
|
||||||
/// 列表卡片只带一集可播预览,需要完整剧集时再进详情。
|
/// 列表卡片只带一集可播预览,需要完整剧集时再进详情。
|
||||||
final bool detailLoaded;
|
final bool detailLoaded;
|
||||||
@@ -104,6 +108,7 @@ class Drama {
|
|||||||
isHot: json['isHot'] == true,
|
isHot: json['isHot'] == true,
|
||||||
// viewCount 后端是 Long,按字符串下发(见 json_numbers.dart)
|
// viewCount 后端是 Long,按字符串下发(见 json_numbers.dart)
|
||||||
viewCount: jsonToInt(json['viewCount']),
|
viewCount: jsonToInt(json['viewCount']),
|
||||||
|
language: json['language'] as String? ?? '',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,6 +125,7 @@ class Drama {
|
|||||||
freeEpisodes: (json['freeEpisodes'] as num?)?.toInt() ?? 0,
|
freeEpisodes: (json['freeEpisodes'] as num?)?.toInt() ?? 0,
|
||||||
isHot: json['isHot'] == true,
|
isHot: json['isHot'] == true,
|
||||||
viewCount: jsonToInt(json['viewCount']),
|
viewCount: jsonToInt(json['viewCount']),
|
||||||
|
language: json['language'] as String? ?? '',
|
||||||
detailLoaded: true,
|
detailLoaded: true,
|
||||||
episodes: (json['episodes'] as List? ?? const [])
|
episodes: (json['episodes'] as List? ?? const [])
|
||||||
.whereType<Map<String, dynamic>>()
|
.whereType<Map<String, dynamic>>()
|
||||||
|
|||||||
@@ -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<Drama> list;
|
||||||
|
|
||||||
|
factory SearchResult.fromJson(Map<String, dynamic> json) {
|
||||||
|
return SearchResult(
|
||||||
|
total: jsonToInt(json['total']),
|
||||||
|
list: (json['list'] as List? ?? const [])
|
||||||
|
.whereType<Map<String, dynamic>>()
|
||||||
|
.map(Drama.fromCardJson)
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import '../models/drama.dart';
|
import '../models/drama.dart';
|
||||||
import '../models/home_feed.dart';
|
import '../models/home_feed.dart';
|
||||||
|
import '../models/search_result.dart';
|
||||||
import 'api_client.dart';
|
import 'api_client.dart';
|
||||||
|
|
||||||
/// 客户端目录接口,对应后端 `/app/**`。
|
/// 客户端目录接口,对应后端 `/app/**`。
|
||||||
@@ -16,6 +17,23 @@ class CatalogApi {
|
|||||||
return HomeFeed.fromJson(_asMap(await _client.get('/app/discover/feed')));
|
return HomeFeed.fromJson(_asMap(await _client.get('/app/discover/feed')));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 搜索已上线短剧:按标题/简介模糊匹配,分页返回卡片列表。
|
||||||
|
Future<SearchResult> 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<Drama> dramaDetail(String dramaId) async {
|
Future<Drama> dramaDetail(String dramaId) async {
|
||||||
return Drama.fromDetailJson(
|
return Drama.fromDetailJson(
|
||||||
_asMap(await _client.get('/app/dramas/$dramaId')),
|
_asMap(await _client.get('/app/dramas/$dramaId')),
|
||||||
|
|||||||
@@ -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<SearchResult> search(
|
||||||
|
String keyword, {
|
||||||
|
int pageIndex = 1,
|
||||||
|
int pageSize = 20,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class HttpSearchRepository implements SearchRepository {
|
||||||
|
const HttpSearchRepository(this._api);
|
||||||
|
|
||||||
|
final CatalogApi _api;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<SearchResult> search(
|
||||||
|
String keyword, {
|
||||||
|
int pageIndex = 1,
|
||||||
|
int pageSize = 20,
|
||||||
|
}) => _api.search(keyword, pageIndex: pageIndex, pageSize: pageSize);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
// Author: fruit.com.cn
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
import '../../core/data/models/drama.dart';
|
import '../../core/data/models/drama.dart';
|
||||||
|
import '../../core/data/repositories/search_repository.dart';
|
||||||
import '../../core/data/repositories/watch_history_repository.dart';
|
import '../../core/data/repositories/watch_history_repository.dart';
|
||||||
import 'discover_logic.dart';
|
import 'discover_logic.dart';
|
||||||
|
|
||||||
@@ -8,5 +9,10 @@ class DiscoverBinding {
|
|||||||
DiscoverLogic create({
|
DiscoverLogic create({
|
||||||
required Drama drama,
|
required Drama drama,
|
||||||
required WatchHistoryRepository history,
|
required WatchHistoryRepository history,
|
||||||
}) => DiscoverLogic(drama: drama, history: history);
|
SearchRepository? search,
|
||||||
|
}) => DiscoverLogic(
|
||||||
|
drama: drama,
|
||||||
|
history: history,
|
||||||
|
searchRepository: search,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -1,20 +1,29 @@
|
|||||||
// Author: fruit.com.cn
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../../core/data/models/drama.dart';
|
import '../../core/data/models/drama.dart';
|
||||||
import '../../core/data/models/watch_progress.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 '../../core/data/repositories/watch_history_repository.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
|
|
||||||
enum DiscoverPageAction { home, my, watchlist, detail, resume }
|
enum DiscoverPageAction { home, my, watchlist, detail, resume }
|
||||||
|
|
||||||
class DiscoverLogic extends ChangeNotifier {
|
class DiscoverLogic extends ChangeNotifier {
|
||||||
DiscoverLogic({required this.history, required this.drama}) {
|
DiscoverLogic({
|
||||||
|
required this.history,
|
||||||
|
required this.drama,
|
||||||
|
this.searchRepository,
|
||||||
|
}) {
|
||||||
refreshPosition();
|
refreshPosition();
|
||||||
}
|
}
|
||||||
|
|
||||||
final WatchHistoryRepository history;
|
final WatchHistoryRepository history;
|
||||||
|
final SearchRepository? searchRepository;
|
||||||
Drama drama;
|
Drama drama;
|
||||||
late Future<Duration?> position;
|
late Future<Duration?> position;
|
||||||
WatchProgress? resumeRecord;
|
WatchProgress? resumeRecord;
|
||||||
@@ -22,6 +31,17 @@ class DiscoverLogic extends ChangeNotifier {
|
|||||||
final FocusNode searchFocus = FocusNode();
|
final FocusNode searchFocus = FocusNode();
|
||||||
final ScrollController scroll = ScrollController();
|
final ScrollController scroll = ScrollController();
|
||||||
bool searchOpen = false;
|
bool searchOpen = false;
|
||||||
|
|
||||||
|
/// 搜索防抖与分页口径:与后端 `/app/search` 一致。
|
||||||
|
Duration searchDebounce = const Duration(milliseconds: 350);
|
||||||
|
static const int searchPageSize = 20;
|
||||||
|
List<Drama> searchResults = const [];
|
||||||
|
int searchTotal = 0;
|
||||||
|
bool searchLoading = false;
|
||||||
|
String? searchError;
|
||||||
|
Timer? _searchTimer;
|
||||||
|
String _lastRequestedQuery = '';
|
||||||
|
int _searchRequest = 0;
|
||||||
bool _disposed = false;
|
bool _disposed = false;
|
||||||
|
|
||||||
void notify() {
|
void notify() {
|
||||||
@@ -51,20 +71,89 @@ class DiscoverLogic extends ChangeNotifier {
|
|||||||
notify();
|
notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
void onSearchChanged() => notify();
|
bool get hasSearchQuery => search.text.trim().isNotEmpty;
|
||||||
|
|
||||||
List<Drama> filtered(List<Drama> dramas) {
|
/// 输入变化只触发后端查询;本地不再做标题/简介匹配。
|
||||||
final query = search.text.trim().toLowerCase();
|
void onSearchChanged() {
|
||||||
if (query.isEmpty) return dramas;
|
final query = search.text.trim();
|
||||||
return dramas.where((drama) {
|
if (query.isEmpty) {
|
||||||
final haystack = [
|
_resetSearch();
|
||||||
drama.title,
|
notify();
|
||||||
drama.genre,
|
return;
|
||||||
drama.category,
|
}
|
||||||
drama.synopsis,
|
notify();
|
||||||
].join(' ').toLowerCase();
|
_scheduleSearch(query);
|
||||||
return haystack.contains(query);
|
}
|
||||||
}).toList();
|
|
||||||
|
/// 搜索态展示后端结果,非搜索态展示剧场自身的可见短剧。
|
||||||
|
List<Drama> visibleDramas(List<Drama> fallback) {
|
||||||
|
if (!hasSearchQuery) return fallback;
|
||||||
|
return searchResults;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> 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<void> _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<Drama> dramas) {
|
Drama? resumeDrama(List<Drama> dramas) {
|
||||||
@@ -165,6 +254,7 @@ class DiscoverLogic extends ChangeNotifier {
|
|||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_disposed = true;
|
_disposed = true;
|
||||||
|
_searchTimer?.cancel();
|
||||||
search.dispose();
|
search.dispose();
|
||||||
searchFocus.dispose();
|
searchFocus.dispose();
|
||||||
scroll.dispose();
|
scroll.dispose();
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ import '../../core/config/app_config.dart';
|
|||||||
import '../../core/data/models/drama.dart';
|
import '../../core/data/models/drama.dart';
|
||||||
import '../../core/data/models/home_feed.dart';
|
import '../../core/data/models/home_feed.dart';
|
||||||
import '../../core/data/models/watch_progress.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/repositories/watch_history_repository.dart';
|
||||||
import '../../core/data/services/category_controller.dart';
|
import '../../core/data/services/category_controller.dart';
|
||||||
|
import '../../core/formatting/error_text.dart';
|
||||||
import '../../core/theme/app_colors.dart';
|
import '../../core/theme/app_colors.dart';
|
||||||
import '../../core/theme/app_dimens.dart';
|
import '../../core/theme/app_dimens.dart';
|
||||||
import '../../core/theme/app_theme.dart';
|
import '../../core/theme/app_theme.dart';
|
||||||
@@ -30,6 +32,7 @@ class DiscoverPage extends StatefulWidget {
|
|||||||
required this.duration,
|
required this.duration,
|
||||||
this.focusSearch = false,
|
this.focusSearch = false,
|
||||||
this.history,
|
this.history,
|
||||||
|
this.searchRepository,
|
||||||
this.embedded = false,
|
this.embedded = false,
|
||||||
this.active = true,
|
this.active = true,
|
||||||
this.searchRequest = 0,
|
this.searchRequest = 0,
|
||||||
@@ -48,6 +51,7 @@ class DiscoverPage extends StatefulWidget {
|
|||||||
final Duration duration;
|
final Duration duration;
|
||||||
final bool focusSearch;
|
final bool focusSearch;
|
||||||
final WatchHistoryRepository? history;
|
final WatchHistoryRepository? history;
|
||||||
|
final SearchRepository? searchRepository;
|
||||||
final bool embedded;
|
final bool embedded;
|
||||||
final bool active;
|
final bool active;
|
||||||
final int searchRequest;
|
final int searchRequest;
|
||||||
@@ -74,6 +78,7 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
|||||||
logic = DiscoverBinding().create(
|
logic = DiscoverBinding().create(
|
||||||
drama: widget.drama,
|
drama: widget.drama,
|
||||||
history: widget.history ?? context.read<WatchHistoryRepository>(),
|
history: widget.history ?? context.read<WatchHistoryRepository>(),
|
||||||
|
search: widget.searchRepository ?? context.read<SearchRepository>(),
|
||||||
);
|
);
|
||||||
if (widget.focusSearch) {
|
if (widget.focusSearch) {
|
||||||
logic.searchOpen = true;
|
logic.searchOpen = true;
|
||||||
@@ -115,7 +120,7 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
|||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
final categories = context.watch<CategoryController>();
|
final categories = context.watch<CategoryController>();
|
||||||
final allDramas = [...?widget.dramas, ...categories.visibleDramas];
|
final allDramas = [...?widget.dramas, ...categories.visibleDramas];
|
||||||
final visible = logic.filtered(categories.visibleDramas);
|
final visible = logic.visibleDramas(categories.visibleDramas);
|
||||||
final content = SafeArea(
|
final content = SafeArea(
|
||||||
bottom: !widget.embedded,
|
bottom: !widget.embedded,
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -235,7 +240,10 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
|||||||
l10n: l10n,
|
l10n: l10n,
|
||||||
categories: categories,
|
categories: categories,
|
||||||
dramas: visible,
|
dramas: visible,
|
||||||
searching: logic.search.text.trim().isNotEmpty,
|
searching: logic.hasSearchQuery,
|
||||||
|
searchLoading: logic.searchLoading,
|
||||||
|
searchError: logic.searchError,
|
||||||
|
onRetrySearch: () => logic.retrySearch(),
|
||||||
onOpen: (drama) => logic.openCard(
|
onOpen: (drama) => logic.openCard(
|
||||||
context,
|
context,
|
||||||
drama,
|
drama,
|
||||||
@@ -329,6 +337,7 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
|||||||
controller: logic.search,
|
controller: logic.search,
|
||||||
focusNode: logic.searchFocus,
|
focusNode: logic.searchFocus,
|
||||||
onChanged: (_) => logic.onSearchChanged(),
|
onChanged: (_) => logic.onSearchChanged(),
|
||||||
|
|
||||||
onTapOutside: (_) => logic.searchFocus.unfocus(),
|
onTapOutside: (_) => logic.searchFocus.unfocus(),
|
||||||
textInputAction: TextInputAction.search,
|
textInputAction: TextInputAction.search,
|
||||||
style: const TextStyle(color: AppColors.onSurface, fontSize: 13),
|
style: const TextStyle(color: AppColors.onSurface, fontSize: 13),
|
||||||
@@ -343,6 +352,12 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
|||||||
color: AppColors.subtle,
|
color: AppColors.subtle,
|
||||||
size: 17,
|
size: 17,
|
||||||
),
|
),
|
||||||
|
suffixIcon: logic.searchLoading
|
||||||
|
? const Padding(
|
||||||
|
padding: EdgeInsets.all(11),
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
|
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
enabledBorder: OutlineInputBorder(
|
enabledBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ class _HomeDramaGrid extends StatelessWidget {
|
|||||||
required this.categories,
|
required this.categories,
|
||||||
required this.dramas,
|
required this.dramas,
|
||||||
required this.searching,
|
required this.searching,
|
||||||
|
required this.searchLoading,
|
||||||
|
required this.searchError,
|
||||||
|
required this.onRetrySearch,
|
||||||
required this.onOpen,
|
required this.onOpen,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -15,6 +18,9 @@ class _HomeDramaGrid extends StatelessWidget {
|
|||||||
final CategoryController categories;
|
final CategoryController categories;
|
||||||
final List<Drama> dramas;
|
final List<Drama> dramas;
|
||||||
final bool searching;
|
final bool searching;
|
||||||
|
final bool searchLoading;
|
||||||
|
final String? searchError;
|
||||||
|
final VoidCallback onRetrySearch;
|
||||||
final ValueChanged<Drama> onOpen;
|
final ValueChanged<Drama> onOpen;
|
||||||
|
|
||||||
@override
|
@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) {
|
if (dramas.isEmpty) {
|
||||||
return _fill(
|
return _fill(
|
||||||
EmptyState(
|
EmptyState(
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<Drama> results;
|
||||||
|
final ApiException? error;
|
||||||
|
final List<({String keyword, int pageIndex, int pageSize})> calls = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<SearchResult> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -428,6 +428,7 @@ Future<void> _pumpApp(
|
|||||||
LumaDramaApp(
|
LumaDramaApp(
|
||||||
playerFactory: createWidgetPlayer,
|
playerFactory: createWidgetPlayer,
|
||||||
catalogRepository: catalog,
|
catalogRepository: catalog,
|
||||||
|
searchRepository: catalog,
|
||||||
categoryRepository: categoryRepository,
|
categoryRepository: categoryRepository,
|
||||||
watchHistoryRepository: history ?? InMemoryWatchHistoryRepository(),
|
watchHistoryRepository: history ?? InMemoryWatchHistoryRepository(),
|
||||||
favoriteRepository: InMemoryFavoriteRepository(),
|
favoriteRepository: InMemoryFavoriteRepository(),
|
||||||
|
|||||||
@@ -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/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/catalog_repository.dart';
|
||||||
|
import 'package:fruitshorts/core/data/repositories/search_repository.dart';
|
||||||
|
|
||||||
/// 测试用目录数据:三部分区里的短剧,标题与后端演示数据保持一致。
|
/// 测试用目录数据:三部分区里的短剧,标题与后端演示数据保持一致。
|
||||||
class FakeCatalogRepository implements CatalogRepository {
|
class FakeCatalogRepository implements CatalogRepository, SearchRepository {
|
||||||
FakeCatalogRepository({List<Drama>? dramas, this.banners = const []})
|
FakeCatalogRepository({List<Drama>? dramas, this.banners = const []})
|
||||||
: dramas = dramas ?? demoDramas;
|
: dramas = dramas ?? demoDramas;
|
||||||
|
|
||||||
@@ -21,6 +23,20 @@ class FakeCatalogRepository implements CatalogRepository {
|
|||||||
return dramas.firstWhere((drama) => drama.id == dramaId);
|
return dramas.firstWhere((drama) => drama.id == dramaId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<SearchResult> 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(
|
HomeFeed _feed() => HomeFeed(
|
||||||
banners: banners,
|
banners: banners,
|
||||||
sections: [FeedSection(code: 'hot', title: '热门推荐', dramas: dramas)],
|
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',
|
synopsis: '$title synopsis',
|
||||||
posterUrl: '/files/$id.png',
|
posterUrl: '/files/$id.png',
|
||||||
genre: genre,
|
genre: genre,
|
||||||
|
language: 'id',
|
||||||
detailLoaded: true,
|
detailLoaded: true,
|
||||||
episodes: [
|
episodes: [
|
||||||
for (var index = 0; index < 7; index++)
|
for (var index = 0; index < 7; index++)
|
||||||
|
|||||||
Reference in new issue
Block a user