feat(app): 客户端展示多语言并支持筛选分页

This commit is contained in:
Zhou Weidong committed 2026-10-24 17:20:24 +08:00
1 parent 8481611372
commit 8b50b5bd06
68 files changed
+2742 -1120

No files matched your search

@@ -0,0 +1,173 @@
// Author: fruit.com.cn
// 全部短剧筛选与分页:拉取分类、类型、标签,并按页加载短剧。
import '../models/content_category.dart';
import '../models/drama.dart';
import '../models/page_result.dart';
import '../remote/catalog_api.dart';
/// 全部短剧筛选与分页所需的远端能力。
abstract interface class AllDramasRemote {
/// 已启用分类。
Future<List<ContentCategory>> classifications();
/// 已启用类型。
Future<List<ContentCategory>> categories();
/// 已启用标签。
Future<List<ContentCategory>> tags();
/// 按当前筛选取一页短剧。
Future<PageResult<Drama>> dramas({
required List<String> categoryIds,
required List<String> classificationIds,
required List<String> tagIds,
required int pageIndex,
});
}
/// 用现有目录接口实现全部短剧远端能力。
class CatalogAllDramasRemote implements AllDramasRemote {
/// 绑定目录接口。
const CatalogAllDramasRemote(this._api);
final CatalogApi _api;
@override
Future<List<ContentCategory>> classifications() => _api.classifications();
@override
Future<List<ContentCategory>> categories() => _api.dramaCategories();
@override
Future<List<ContentCategory>> tags() => _api.tags();
@override
Future<PageResult<Drama>> dramas({
required List<String> categoryIds,
required List<String> classificationIds,
required List<String> tagIds,
required int pageIndex,
}) {
return _api.libraryDramas(
categoryIds: categoryIds,
classificationIds: classificationIds,
tagIds: tagIds,
pageIndex: pageIndex,
);
}
}
/// 全部短剧:筛选项来自类型、分类、标签,列表按页加载。
class AllDramasController {
AllDramasController({
AllDramasRemote? api,
this.classifications = const [],
this.categories = const [],
this.tags = const [],
this.dramas = const [],
}) : _api = api;
final AllDramasRemote? _api;
List<ContentCategory> classifications;
List<ContentCategory> categories;
List<ContentCategory> tags;
final Set<String> selectedClassifications = {};
final Set<String> selectedCategories = {};
final Set<String> selectedTags = {};
List<Drama> dramas;
bool loading = false;
bool loadingMore = false;
bool hasMore = false;
String? error;
int _page = 1;
int _total = 0;
bool get usesRemote => _api != null;
/// 预览用:筛选项和列表都已在本地,不请求接口。
factory AllDramasController.preview({
List<ContentCategory> classifications = const [],
List<ContentCategory> categories = const [],
List<ContentCategory> tags = const [],
List<Drama> dramas = const [],
}) {
return AllDramasController(
classifications: classifications,
categories: categories,
tags: tags,
dramas: dramas,
);
}
/// 从第一页重新拉取筛选项和短剧。没有接口时直接返回。
Future<void> refresh() async {
if (_api == null) return;
loading = true;
error = null;
_page = 1;
try {
final filters = await Future.wait([
_api.classifications(),
_api.categories(),
_api.tags(),
]);
classifications = filters[0];
categories = filters[1];
tags = filters[2];
final page = await _loadPage(1);
dramas = page;
hasMore = dramas.length < _total;
} catch (caught) {
error = '$caught';
} finally {
loading = false;
}
}
/// 追加下一页。没有更多、正在加载或没有接口时直接返回。
Future<void> loadMore() async {
if (_api == null || loading || loadingMore || !hasMore) return;
loadingMore = true;
error = null;
try {
final next = await _loadPage(_page + 1);
_page += 1;
dramas = [...dramas, ...next];
hasMore = dramas.length < _total;
} catch (caught) {
error = '$caught';
} finally {
loadingMore = false;
}
}
/// 切换一个分类。传入空字符串表示清空这一组。
void toggleClassification(String id) =>
_toggle(selectedClassifications, id);
/// 切换一个类型。传入空字符串表示清空这一组。
void toggleCategory(String id) => _toggle(selectedCategories, id);
/// 切换一个标签。传入空字符串表示清空这一组。
void toggleTag(String id) => _toggle(selectedTags, id);
void _toggle(Set<String> selected, String id) {
if (id.isEmpty) {
selected.clear();
return;
}
if (!selected.add(id)) selected.remove(id);
}
Future<List<Drama>> _loadPage(int pageIndex) async {
final page = await _api!.dramas(
categoryIds: selectedCategories.toList(growable: false),
classificationIds: selectedClassifications.toList(growable: false),
tagIds: selectedTags.toList(growable: false),
pageIndex: pageIndex,
);
_total = page.total;
return page.list;
}
}
@@ -6,6 +6,8 @@ import '../../config/app_config.dart';
import '../models/content_category.dart';
import '../models/drama.dart';
import '../models/home_feed.dart';
import '../models/page_result.dart';
import '../remote/category_api.dart';
import '../repositories/category_repository.dart';
/// 剧场页数据:服务端分类清单与发现 feed。
@@ -14,13 +16,80 @@ class CategoryController extends ChangeNotifier {
final CategoryRepository _repository;
/// 生产环境注入。未注入时首页分类继续用本地筛选。
Future<PageResult<Drama>> Function(
String categoryId, {
int pageIndex,
int pageSize,
})? dramaPageLoader;
List<ContentCategory> categories = const [];
final Map<String, CategoryDramaSlot> _slots = {};
HomeFeed feed = const HomeFeed();
bool loading = false;
String? error;
String? debugError;
bool _disposed = false;
/// 某个分类自己的短剧分页,不和别的分类共用。
CategoryDramaSlot slotOf(String categoryId) {
return _slots.putIfAbsent(categoryId, CategoryDramaSlot.new);
}
/// 打开分类并加载第一页短剧。
Future<void> openCategory(String categoryId) async {
final loader = dramaPageLoader;
if (loader == null) return;
final slot = slotOf(categoryId);
slot.loading = true;
slot.loadingMore = false;
_notify();
try {
final page = await loader(
categoryId,
pageIndex: 1,
pageSize: CategoryApi.dramaPageSize,
);
slot.dramas = page.list;
slot.total = page.total;
slot.pageIndex = 1;
} catch (exception) {
if (kDebugMode) debugPrint('[category] $exception');
} finally {
slot.loading = false;
_notify();
}
}
/// 上拉加载这个分类的下一页。
Future<void> loadMore(String categoryId) async {
final loader = dramaPageLoader;
final slot = _slots[categoryId];
if (loader == null || slot == null) return;
if (slot.loading || slot.loadingMore || !slot.hasMore) return;
slot.loadingMore = true;
_notify();
try {
final page = await loader(
categoryId,
pageIndex: slot.pageIndex + 1,
pageSize: CategoryApi.dramaPageSize,
);
if (page.list.isEmpty) {
slot.total = slot.dramas.length;
} else {
slot.dramas = [...slot.dramas, ...page.list];
slot.total = page.total;
slot.pageIndex += 1;
}
} catch (exception) {
if (kDebugMode) debugPrint('[category] more $exception');
} finally {
slot.loadingMore = false;
_notify();
}
}
/// 分类九宫格点击后读取该分类的剧目;缺失分区返回空列表。
List<Drama> dramasOf(String categoryId) {
final code = 'category-$categoryId';
@@ -30,19 +99,23 @@ class CategoryController extends ChangeNotifier {
return const [];
}
/// 加载分类与发现 feed;失败保留可重试错误态。
/// 首页只拉分类,再拉第一个分类的短剧。发现页聚合留到没有短剧分页时再取。
Future<void> load() async {
loading = true;
error = null;
debugError = null;
_notify();
try {
final results = await Future.wait<Object>([
_repository.fetchCategories(),
_repository.fetchDiscoverFeed(),
]);
categories = results[0] as List<ContentCategory>;
feed = results[1] as HomeFeed;
if (dramaPageLoader != null) {
categories = await _repository.fetchCategories();
} else {
final results = await Future.wait<Object>([
_repository.fetchCategories(),
_repository.fetchDiscoverFeed(),
]);
categories = results[0] as List<ContentCategory>;
feed = results[1] as HomeFeed;
}
} catch (exception) {
error = 'categoriesError';
debugError = exception.toString();
@@ -53,6 +126,9 @@ class CategoryController extends ChangeNotifier {
loading = false;
_notify();
}
if (error == null && dramaPageLoader != null && categories.isNotEmpty) {
await openCategory(categories.first.id);
}
}
void _notify() {
@@ -65,3 +141,14 @@ class CategoryController extends ChangeNotifier {
super.dispose();
}
}
/// 单个分类的短剧分页状态。
class CategoryDramaSlot {
List<Drama> dramas = const [];
int total = 0;
int pageIndex = 0;
bool loading = false;
bool loadingMore = false;
bool get hasMore => dramas.length < total;
}
@@ -0,0 +1,107 @@
// Author: fruit.com.cn
import 'package:flutter/foundation.dart';
import '../models/playlist_detail.dart';
import '../remote/catalog_api.dart';
import '../repositories/playlist_repository.dart';
/// 推荐页剧单:已启用剧单及其短剧卡片。
class PlaylistController extends ChangeNotifier {
PlaylistController(this._repository);
final PlaylistRepository _repository;
static const int pageSize = CatalogApi.playlistPageSize;
List<PlaylistDetail> items = const [];
bool loading = false;
bool loadingMore = false;
bool loaded = false;
bool hasMore = false;
String? error;
int _pageIndex = 1;
bool _disposed = false;
/// 从第一页重新拉取。失败保留错误态,成功后替换本地缓存。
Future<void> load() async {
loading = true;
error = null;
_notify();
try {
final page = await _repository.fetchPlaylists(pageSize: pageSize);
items = page.list;
_pageIndex = 1;
hasMore = items.length < page.total;
loaded = true;
} catch (exception) {
error = 'playlistsError';
if (kDebugMode) debugPrint('[playlist] $exception');
} finally {
loading = false;
_notify();
}
}
/// 滚到底时追加下一页。没有更多或正在请求时直接返回。
Future<void> loadMore() async {
if (loading || loadingMore || !hasMore) return;
loadingMore = true;
_notify();
try {
final page = await _repository.fetchPlaylists(
pageIndex: _pageIndex + 1,
pageSize: pageSize,
);
if (page.list.isEmpty) {
hasMore = false;
} else {
items = [...items, ...page.list];
_pageIndex += 1;
hasMore = items.length < page.total;
}
} catch (exception) {
if (kDebugMode) debugPrint('[playlist] more $exception');
} finally {
loadingMore = false;
_notify();
}
}
/// 打开某一剧单时再取一次详情;失败则回落到列表里已有的那条。
Future<PlaylistDetail> detail(
String id, {
int pageIndex = 1,
int pageSize = CatalogApi.playlistDramaPageSize,
}) async {
try {
return await _repository.fetchPlaylist(
id,
pageIndex: pageIndex,
pageSize: pageSize,
);
} catch (exception) {
if (kDebugMode) debugPrint('[playlist] detail $id $exception');
return items.firstWhere(
(item) => item.id == id,
orElse: () => PlaylistDetail(
id: id,
name: '',
description: '',
dramaCount: 0,
dramas: const [],
),
);
}
}
void _notify() {
if (!_disposed) notifyListeners();
}
@override
void dispose() {
_disposed = true;
super.dispose();
}
}