264 lines
6.7 KiB
Dart
264 lines
6.7 KiB
Dart
// 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<Duration?> 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<Drama> 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<void> 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<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) {
|
|
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<DiscoverPageAction>? onAction,
|
|
}) {
|
|
if (embedded) {
|
|
onAction?.call(action);
|
|
} else {
|
|
Navigator.pop(context, action);
|
|
}
|
|
}
|
|
|
|
Drama? dramaFor(String? dramaId, List<Drama> dramas) {
|
|
for (final item in dramas) {
|
|
if (item.id == dramaId) return item;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
void openCard(
|
|
BuildContext context,
|
|
Drama? selected, {
|
|
required bool embedded,
|
|
ValueChanged<Drama>? onDramaSelected,
|
|
ValueChanged<DiscoverPageAction>? 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();
|
|
}
|
|
}
|