// 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, this.searchRepository, }) { refreshPosition(); } final WatchHistoryRepository history; final SearchRepository? searchRepository; Drama drama; late Future position; WatchProgress? resumeRecord; final TextEditingController search = TextEditingController(); 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() { if (!_disposed) notifyListeners(); } void refreshPosition() { // 续看位置来自服务端(每剧一条记录);未登录或没有记录时给 null,UI 显示"从头看" position = history .resumePointFor(drama.id) .then((point) => point?.position) .catchError((Object _) => null); refreshResume(); } Future refreshResume() async { try { final recent = await history.recent(limit: 1); if (recent.isEmpty || recent.first.finished) { resumeRecord = null; } else { resumeRecord = recent.first; } } catch (_) { resumeRecord = null; } notify(); } bool get hasSearchQuery => search.text.trim().isNotEmpty; /// 输入变化只触发后端查询;本地不再做标题/简介匹配。 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) { final record = resumeRecord; if (record == null) return null; for (final drama in dramas) { if (drama.id == record.dramaId) return drama; } return null; } bool get showSearch => searchOpen || search.text.isNotEmpty; void openSearch() { searchOpen = true; notify(); searchFocus.requestFocus(); } void syncDrama({ required bool active, required bool wasActive, required Drama nextDrama, required bool dramaChanged, required int searchRequest, required int previousSearchRequest, required bool Function() isActive, }) { if (active && (!wasActive || dramaChanged)) { drama = nextDrama; refreshPosition(); notify(); } if (active && searchRequest != previousSearchRequest) { searchOpen = true; notify(); WidgetsBinding.instance.addPostFrameCallback((_) { if (isActive()) searchFocus.requestFocus(); }); } } void act( BuildContext context, DiscoverPageAction action, { required bool embedded, ValueChanged? onAction, }) { if (embedded) { onAction?.call(action); } else { Navigator.pop(context, action); } } Drama? dramaFor(String? dramaId, List dramas) { for (final item in dramas) { if (item.id == dramaId) return item; } return null; } void openCard( BuildContext context, Drama? selected, { required bool embedded, ValueChanged? onDramaSelected, ValueChanged? onAction, }) { if (selected == null) { unavailable(context); } else if (embedded && onDramaSelected != null) { onDramaSelected(selected); } else { act( context, DiscoverPageAction.detail, embedded: embedded, onAction: onAction, ); } } void unavailable(BuildContext context) { final l10n = AppLocalizations.of(context)!; ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(l10n.artworkOnlyNotice))); } void catalogInfo(BuildContext context) { final l10n = AppLocalizations.of(context)!; ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(l10n.demoCatalogNotice))); } @override void dispose() { _disposed = true; _searchTimer?.cancel(); search.dispose(); searchFocus.dispose(); scroll.dispose(); super.dispose(); } }