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

+18 -5
View File
@@ -1,17 +1,30 @@
// Author: fruit.com.cn
import 'locale_text.dart';
/// 服务端内容分类。id 按契约保持字符串,避免雪花 ID 精度丢失。
class ContentCategory {
const ContentCategory({required this.id, required this.name});
const ContentCategory({
required this.id,
required this.name,
this.locales = const {},
});
final String id;
/// 中文名称,与 locales 里的 zh 相同。
final String name;
final Map<String, String> locales;
/// 解析 `GET /app/categories` 的单项。
factory ContentCategory.fromJson(Map<String, dynamic> json) {
return ContentCategory(
id: (json['id'] ?? '').toString(),
name: json['name'] as String? ?? '',
);
final locales = localeMap(json['locales']);
final name = json['name'] as String? ?? locales['zh'] ?? '';
return ContentCategory(id: (json['id'] ?? '').toString(), name: name, locales: locales);
}
/// 按界面语言取名称。缺对应语言时回落到中文。
String localizedName(String languageCode) {
return localizedText(languageCode, name, locales);
}
}
+150 -9
View File
@@ -1,6 +1,9 @@
// Author: fruit.com.cn
import 'dart:ui';
import 'json_numbers.dart';
import 'locale_text.dart';
/// 短剧与剧集,字段与后端 `/app/**` 的下发结构对应。
///
@@ -76,7 +79,14 @@ class Drama {
required this.synopsis,
required this.posterUrl,
this.episodes = const [],
this.titleLocales = const {},
this.synopsisLocales = const {},
this.tag = '',
this.tagLocales = const {},
this.tagColor = '',
this.category = '',
this.categoryLocales = const {},
this.types = const [],
this.genre = '',
this.totalEpisodes = 0,
this.freeEpisodes = 0,
@@ -94,9 +104,16 @@ class Drama {
final String id;
final String title;
final String synopsis;
final Map<String, String> titleLocales;
final Map<String, String> synopsisLocales;
final String tag;
final Map<String, String> tagLocales;
final String tagColor;
final String posterUrl;
final List<Episode> episodes;
final String category;
final Map<String, String> categoryLocales;
final List<Map<String, String>> types;
final String genre;
final int totalEpisodes;
final int freeEpisodes;
@@ -127,19 +144,26 @@ class Drama {
/// 列表卡片:带一集可播预览(首页直接起播),完整剧集进详情再取。
factory Drama.fromCardJson(Map<String, dynamic> json) {
final preview = json['preview'];
final text = _DramaText.fromJson(json);
return Drama(
id: (json['id'] ?? '').toString(),
title: json['title'] as String? ?? '',
synopsis: json['synopsis'] as String? ?? '',
title: text.title,
synopsis: text.synopsis,
titleLocales: text.titleLocales,
synopsisLocales: text.synopsisLocales,
tag: text.tag,
tagLocales: text.tagLocales,
tagColor: json['tagColor'] as String? ?? '',
posterUrl: json['cover'] as String? ?? '',
episodes: preview is Map<String, dynamic>
? [Episode.fromJson(preview)]
: const [],
category: json['category'] as String? ?? '',
genre: json['genre'] as String? ?? '',
category: text.category,
categoryLocales: text.categoryLocales,
types: text.types,
genre: text.genre,
totalEpisodes: (json['totalEpisodes'] as num?)?.toInt() ?? 0,
isHot: json['isHot'] == true,
// viewCount 后端是 Long,按字符串下发(见 json_numbers.dart)
viewCount: jsonToInt(json['viewCount']),
likeCount: _optionalInt(json['likeCount']),
favoriteCount: _optionalInt(json['favoriteCount']),
@@ -152,13 +176,21 @@ class Drama {
/// 详情:带剧集列表。
factory Drama.fromDetailJson(Map<String, dynamic> json) {
final text = _DramaText.fromJson(json);
return Drama(
id: (json['id'] ?? '').toString(),
title: json['title'] as String? ?? '',
synopsis: json['synopsis'] as String? ?? '',
title: text.title,
synopsis: text.synopsis,
titleLocales: text.titleLocales,
synopsisLocales: text.synopsisLocales,
tag: text.tag,
tagLocales: text.tagLocales,
tagColor: json['tagColor'] as String? ?? '',
posterUrl: json['cover'] as String? ?? '',
category: json['category'] as String? ?? '',
genre: json['genre'] as String? ?? '',
category: text.category,
categoryLocales: text.categoryLocales,
types: text.types,
genre: text.genre,
totalEpisodes: (json['totalEpisodes'] as num?)?.toInt() ?? 0,
freeEpisodes: (json['freeEpisodes'] as num?)?.toInt() ?? 0,
isHot: json['isHot'] == true,
@@ -176,6 +208,37 @@ class Drama {
.toList(),
);
}
String localizedTitle(String languageCode) {
return localizedText(languageCode, title, titleLocales);
}
String localizedSynopsis(String languageCode) {
return localizedText(languageCode, synopsis, synopsisLocales);
}
String localizedTag(String languageCode) {
return localizedText(languageCode, tag, tagLocales);
}
/// 标签角标背景色。接口没给合法色值时用 fallback。
Color tagRibbonColor(Color fallback) {
final parsed = _colorFromHex(tagColor);
return parsed ?? fallback;
}
/// 按当前语言取类型名称。接口没带语言表时返回空,调用方继续用中文 genre。
List<String> localizedTypeLabels(String languageCode) {
final labels = <String>[];
for (final item in types) {
final label = localizedText(languageCode, item['zh'] ?? '', item);
if (label.isNotEmpty && !labels.contains(label)) labels.add(label);
}
if (labels.isNotEmpty) return labels;
final primary = localizedText(languageCode, '', categoryLocales);
if (primary.isNotEmpty) return [primary];
return const [];
}
}
int? _optionalInt(dynamic value) => value == null ? null : jsonToInt(value);
@@ -184,3 +247,81 @@ String? _optionalString(dynamic value) {
final text = value is String ? value.trim() : '';
return text.isEmpty ? null : text;
}
Color? _colorFromHex(String raw) {
final text = raw.trim();
if (text.length != 7 || !text.startsWith('#')) return null;
final value = int.tryParse(text.substring(1), radix: 16);
if (value == null) return null;
return Color(0xFF000000 | value);
}
List<Map<String, String>> _typeLocales(Object? raw) {
if (raw is! List) return const [];
return raw.whereType<Map>().map(localeMap).toList(growable: false);
}
/// 从语言表还原中文展示字段。列表接口不再单独下发 title / synopsis / tag。
class _DramaText {
const _DramaText({
required this.title,
required this.synopsis,
required this.titleLocales,
required this.synopsisLocales,
required this.tag,
required this.tagLocales,
required this.category,
required this.categoryLocales,
required this.types,
required this.genre,
});
final String title;
final String synopsis;
final Map<String, String> titleLocales;
final Map<String, String> synopsisLocales;
final String tag;
final Map<String, String> tagLocales;
final String category;
final Map<String, String> categoryLocales;
final List<Map<String, String>> types;
final String genre;
factory _DramaText.fromJson(Map<String, dynamic> json) {
final titleLocales = localeMap(json['titleLocales']);
final synopsisLocales = localeMap(json['synopsisLocales']);
final tagLocales = localeMap(json['tagLocales']);
final categoryLocales = localeMap(json['categoryLocales']);
final types = _typeLocales(json['types']);
final category = _firstText(categoryLocales, json['category'], types);
return _DramaText(
title: _firstText(titleLocales, json['title']),
synopsis: _firstText(synopsisLocales, json['synopsis'] ?? json['description']),
titleLocales: titleLocales,
synopsisLocales: synopsisLocales,
tag: _firstText(tagLocales, json['tag']),
tagLocales: tagLocales,
category: category,
categoryLocales: categoryLocales,
types: types,
genre: _genreText(types, json['genre']),
);
}
}
String _firstText(Map<String, String> locales, Object? fallback, [List<Map<String, String>>? types]) {
final chinese = locales['zh'] ?? '';
if (chinese.isNotEmpty) return chinese;
if (types != null && types.isNotEmpty) {
final label = types.first['zh'] ?? '';
if (label.isNotEmpty) return label;
}
return fallback is String ? fallback : '';
}
String _genreText(List<Map<String, String>> types, Object? fallback) {
final names = types.map((item) => item['zh'] ?? '').where((value) => value.isNotEmpty);
final joined = names.join(' / ');
if (joined.isNotEmpty) return joined;
return fallback is String ? fallback : '';
}
+10 -1
View File
@@ -48,7 +48,7 @@ class FeedSection {
factory FeedSection.fromJson(Map<String, dynamic> json) {
return FeedSection(
code: json['code'] as String? ?? '',
title: json['title'] as String? ?? '',
title: _sectionTitle(json),
dramas: (json['dramas'] as List? ?? const [])
.whereType<Map<String, dynamic>>()
.map(Drama.fromCardJson)
@@ -84,3 +84,12 @@ class FeedBanner {
);
}
}
String _sectionTitle(Map<String, dynamic> json) {
final locales = json['titleLocales'];
if (locales is Map) {
final chinese = locales['zh']?.toString() ?? '';
if (chinese.isNotEmpty) return chinese;
}
return json['title'] as String? ?? '';
}
+16
View File
@@ -0,0 +1,16 @@
// Author: fruit.com.cn
/// 把接口里的语言表解析成字符串映射。
Map<String, String> localeMap(Object? raw) {
if (raw is! Map) return const {};
return raw.map((key, value) => MapEntry('$key', value?.toString() ?? ''));
}
/// 按当前语言取文案。没有对应语言时用中文,中文也没有时用 fallback。
String localizedText(String languageCode, String fallback, Map<String, String> locales) {
final selected = locales[languageCode] ?? '';
if (selected.isNotEmpty) return selected;
final chinese = locales['zh'] ?? '';
if (chinese.isNotEmpty) return chinese;
return fallback;
}
+68
View File
@@ -0,0 +1,68 @@
// Author: fruit.com.cn
import 'drama.dart';
import 'locale_text.dart';
/// C 端剧单:`GET /app/playlists` 与 `GET /app/playlists/{id}` 的同一结构。
class PlaylistDetail {
const PlaylistDetail({
required this.id,
required this.name,
required this.description,
this.locales = const {},
this.descriptionLocales = const {},
required this.dramaCount,
required this.dramas,
this.pageIndex = 1,
this.pageSize = 0,
});
final String id;
/// 中文名称,与 locales 里的 zh 相同。
final String name;
final Map<String, String> locales;
/// 中文描述,与 descriptionLocales 里的 zh 相同。
final String description;
final Map<String, String> descriptionLocales;
final int dramaCount;
final int pageIndex;
final int pageSize;
final List<Drama> dramas;
factory PlaylistDetail.fromJson(Map<String, dynamic> json) {
final dramas = _dramasOf(json['list']);
final locales = localeMap(json['locales']);
final descriptions = localeMap(json['descriptionLocales']);
return PlaylistDetail(
id: (json['id'] ?? '').toString(),
name: json['name'] as String? ?? locales['zh'] ?? '',
locales: locales,
description: json['description'] as String? ?? descriptions['zh'] ?? '',
descriptionLocales: descriptions,
dramaCount: (json['dramaCount'] as num?)?.toInt() ?? dramas.length,
pageIndex: (json['pageIndex'] as num?)?.toInt() ?? 1,
pageSize: (json['pageSize'] as num?)?.toInt() ?? dramas.length,
dramas: dramas,
);
}
/// 按界面语言取名称。缺对应语言时回落到中文。
String localizedName(String languageCode) {
return localizedText(languageCode, name, locales);
}
/// 按界面语言取描述。缺对应语言时回落到中文。
String localizedDescription(String languageCode) {
return localizedText(languageCode, description, descriptionLocales);
}
}
List<Drama> _dramasOf(Object? rows) {
if (rows is! List) return const [];
return rows
.whereType<Map>()
.map((item) => Drama.fromCardJson(Map<String, dynamic>.from(item)))
.toList(growable: false);
}
+10
View File
@@ -1,6 +1,7 @@
// Author: fruit.com.cn
import 'json_numbers.dart';
import 'locale_text.dart';
/// 服务端续看记录:`GET /app/watch/progress?dramaId=` 与 `/app/watch/recent` 的下发结构。
///
@@ -13,6 +14,7 @@ class WatchProgress {
required this.position,
required this.duration,
this.dramaTitle,
this.titleLocales = const {},
this.dramaCover,
this.episodeNumber,
this.totalEpisodes,
@@ -25,6 +27,7 @@ class WatchProgress {
final String dramaId;
final String episodeId;
final String? dramaTitle;
final Map<String, String> titleLocales;
final String? dramaCover;
final int? episodeNumber;
final int? totalEpisodes;
@@ -45,11 +48,17 @@ class WatchProgress {
return (position.inMilliseconds / duration.inMilliseconds).clamp(0.0, 1.0);
}
/// 按当前语言取剧名。没有对应语言时回退中文,再回退 dramaTitle。
String localizedTitle(String languageCode) {
return localizedText(languageCode, dramaTitle ?? '', titleLocales);
}
factory WatchProgress.fromJson(Map<String, dynamic> json) {
return WatchProgress(
dramaId: (json['dramaId'] ?? '').toString(),
episodeId: (json['episodeId'] ?? '').toString(),
dramaTitle: json['dramaTitle']?.toString(),
titleLocales: localeMap(json['titleLocales']),
dramaCover: json['dramaCover']?.toString(),
episodeNumber: json['episodeNumber'] == null
? null
@@ -72,6 +81,7 @@ class WatchProgress {
'dramaId': dramaId,
'episodeId': episodeId,
if (dramaTitle != null) 'dramaTitle': dramaTitle,
if (titleLocales.isNotEmpty) 'titleLocales': titleLocales,
if (dramaCover != null) 'dramaCover': dramaCover,
if (episodeNumber != null) 'episodeNumber': episodeNumber,
if (totalEpisodes != null) 'totalEpisodes': totalEpisodes,
+7 -3
View File
@@ -31,9 +31,13 @@ class WatchRecord {
}
/// 服务端分页标题优先,详情标题作为回退。
String get displayTitle {
final pageTitle = progress.dramaTitle?.trim() ?? '';
return pageTitle.isNotEmpty ? pageTitle : drama?.title.trim() ?? '';
String get displayTitle => localizedDisplayTitle('zh');
/// 按当前语言取标题。分页语言表优先,详情语言表作为回退。
String localizedDisplayTitle(String languageCode) {
final pageTitle = progress.localizedTitle(languageCode).trim();
if (pageTitle.isNotEmpty) return pageTitle;
return drama?.localizedTitle(languageCode).trim() ?? '';
}
/// 服务端分页封面优先,详情封面作为回退。
+86
View File
@@ -1,6 +1,9 @@
import '../models/content_category.dart';
import '../models/drama.dart';
import '../models/feed_page.dart';
import '../models/home_feed.dart';
import '../models/page_result.dart';
import '../models/playlist_detail.dart';
import '../models/search_result.dart';
import 'api_client.dart';
@@ -58,12 +61,95 @@ class CatalogApi {
return SearchResult.fromJson(_asMap(data));
}
static const int playlistPageSize = 10;
static const int playlistDramaPageSize = 20;
/// 已启用剧单,分页返回,每条带已上线短剧卡片。
Future<PageResult<PlaylistDetail>> playlists({
int pageIndex = 1,
int pageSize = playlistPageSize,
}) async {
final data = await _client.get(
'/app/playlists',
query: {
'pageIndex': '$pageIndex',
'pageSize': '$pageSize',
},
);
return PageResult.parse(data, PlaylistDetail.fromJson);
}
/// 单个剧单详情。短剧按 pageIndex、pageSize 翻页。
Future<PlaylistDetail> playlist(
String id, {
int pageIndex = 1,
int pageSize = playlistDramaPageSize,
}) async {
return PlaylistDetail.fromJson(
_asMap(
await _client.get(
'/app/playlists/$id',
query: {
'pageIndex': '$pageIndex',
'pageSize': '$pageSize',
},
),
),
);
}
/// 已启用首页分类,名称在 locales。
Future<List<ContentCategory>> classifications() async {
return _categories(await _client.get('/app/categories'));
}
/// 已启用类型,名称在 locales。
Future<List<ContentCategory>> dramaCategories() async {
return _categories(await _client.get('/app/drama-categories'));
}
/// 已启用标签,名称在 locales。
Future<List<ContentCategory>> tags() async {
return _categories(await _client.get('/app/tags'));
}
static const int libraryPageSize = 20;
/// 全部短剧。同一组 id 多选为或,组与组之间为且。
Future<PageResult<Drama>> libraryDramas({
List<String> categoryIds = const [],
List<String> classificationIds = const [],
List<String> tagIds = const [],
int pageIndex = 1,
int pageSize = libraryPageSize,
}) async {
final data = await _client.post(
'/app/dramas/page',
body: {
'pageIndex': pageIndex,
'pageSize': pageSize,
'categoryIds': categoryIds,
'classificationIds': classificationIds,
'tagIds': tagIds,
},
);
return PageResult.parse(data, Drama.fromCardJson);
}
Future<Drama> dramaDetail(String dramaId) async {
return Drama.fromDetailJson(
_asMap(await _client.get('/app/dramas/$dramaId')),
);
}
List<ContentCategory> _categories(Object? data) {
if (data is! List) return const [];
return [
for (final item in data)
if (item is Map<String, dynamic>) ContentCategory.fromJson(item),
];
}
Map<String, dynamic> _asMap(Object? data) {
if (data is Map<String, dynamic>) return data;
throw const ApiException(-1, '返回数据格式异常');
+20
View File
@@ -1,12 +1,16 @@
// Author: fruit.com.cn
import '../models/content_category.dart';
import '../models/drama.dart';
import '../models/page_result.dart';
import 'api_client.dart';
/// 内容分类接口,对应契约 `GET /app/categories`。
class CategoryApi {
const CategoryApi(this._client);
static const int dramaPageSize = 20;
final ApiClient _client;
/// 拉取已启用分类,id 全程按字符串解析。
@@ -18,4 +22,20 @@ class CategoryApi {
if (item is Map<String, dynamic>) ContentCategory.fromJson(item),
];
}
/// 分类下已上线短剧,pageIndex 从 1 开始。
Future<PageResult<Drama>> dramas(
String categoryId, {
int pageIndex = 1,
int pageSize = dramaPageSize,
}) async {
final data = await _client.get(
'/app/categories/$categoryId/dramas',
query: {
'pageIndex': '$pageIndex',
'pageSize': '$pageSize',
},
);
return PageResult.parse(data, Drama.fromCardJson);
}
}
@@ -0,0 +1,38 @@
// Author: fruit.com.cn
import '../models/page_result.dart';
import '../models/playlist_detail.dart';
import '../remote/catalog_api.dart';
/// 剧单来源:列表分页拉取,详情用于打开单条时刷新。
abstract interface class PlaylistRepository {
Future<PageResult<PlaylistDetail>> fetchPlaylists({
int pageIndex = 1,
int pageSize = CatalogApi.playlistPageSize,
});
Future<PlaylistDetail> fetchPlaylist(
String id, {
int pageIndex = 1,
int pageSize = CatalogApi.playlistDramaPageSize,
});
}
class HttpPlaylistRepository implements PlaylistRepository {
HttpPlaylistRepository(this._api);
final CatalogApi _api;
@override
Future<PageResult<PlaylistDetail>> fetchPlaylists({
int pageIndex = 1,
int pageSize = CatalogApi.playlistPageSize,
}) => _api.playlists(pageIndex: pageIndex, pageSize: pageSize);
@override
Future<PlaylistDetail> fetchPlaylist(
String id, {
int pageIndex = 1,
int pageSize = CatalogApi.playlistDramaPageSize,
}) => _api.playlist(id, pageIndex: pageIndex, pageSize: pageSize);
}
@@ -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();
}
}
+2 -2
View File
@@ -8,7 +8,7 @@ String localizedDramaTitle(AppLocalizations l10n, Drama drama) =>
'the-signal-demo' => l10n.dramaSignalTitle,
'city-lights-demo' => l10n.dramaCityTitle,
'vengeance-demo' => l10n.dramaVengeanceTitle,
_ => drama.title,
_ => drama.localizedTitle(l10n.localeName.split(RegExp(r'[_-]')).first),
};
String localizedDramaSynopsis(AppLocalizations l10n, Drama drama) =>
@@ -16,7 +16,7 @@ String localizedDramaSynopsis(AppLocalizations l10n, Drama drama) =>
'the-signal-demo' => l10n.dramaSignalSynopsis,
'city-lights-demo' => l10n.dramaCitySynopsis,
'vengeance-demo' => l10n.dramaVengeanceSynopsis,
_ => drama.synopsis,
_ => drama.localizedSynopsis(l10n.localeName.split(RegExp(r'[_-]')).first),
};
String localizedEpisodeTitle(AppLocalizations l10n, int number) =>
+3
View File
@@ -112,6 +112,9 @@ abstract final class AppColors {
static const textPrimary = Color(0xFFFFFFFF);
static const textSecondary = Color(0xFFB0B0B0);
static const textTertiary = Color(0xFF8E8E93);
/// 列表底部「没有更多了」这类弱提示。
static const textHint = Color(0xFF5C5C60);
static const textTabInactive = Color(0xFFB0B0B0);
static const textFilterIcon = Color(0xFF7A7A7E);
static const searchResultMeta = Color(0xFF9E9E9E);
+128 -47
View File
@@ -1,11 +1,20 @@
// Author: fruit.com.cn
import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../theme/app_colors.dart';
import '../theme/app_dimens.dart';
class DramaGridSkeleton extends StatelessWidget {
const Duration _shimmerPeriod = Duration(milliseconds: 1400);
const double _shimmerTravel = 2.4;
const double _shimmerStart = -1.2;
const double _subtitleWidthFactor = 0.62;
const Color _shimmerHighlight = Color(0x38FFFFFF);
/// 首页短剧网格的占位。行数至少铺满可视高度,并带一道扫光。
class DramaGridSkeleton extends StatefulWidget {
const DramaGridSkeleton({
super.key,
required this.columns,
@@ -23,61 +32,133 @@ class DramaGridSkeleton extends StatelessWidget {
final int itemCount;
final ScrollPhysics physics;
@override
State<DramaGridSkeleton> createState() => _DramaGridSkeletonState();
}
class _DramaGridSkeletonState extends State<DramaGridSkeleton>
with SingleTickerProviderStateMixin {
late final AnimationController _shimmer = AnimationController(
vsync: this,
duration: _shimmerPeriod,
)..repeat();
@override
void dispose() {
_shimmer.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final itemWidth =
(constraints.maxWidth -
horizontalPadding * 2 -
crossAxisSpacing * (columns - 1)) /
columns;
final itemExtent =
itemWidth / AppDimens.cardCoverAspectRatio +
AppDimens.skeletonBodyExtent;
return GridView.builder(
physics: physics,
padding: EdgeInsets.fromLTRB(
horizontalPadding,
AppDimens.spaceMd,
horizontalPadding,
AppDimens.spaceXl,
),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: columns,
crossAxisSpacing: crossAxisSpacing,
mainAxisSpacing: mainAxisSpacing,
mainAxisExtent: itemExtent,
),
itemCount: itemCount,
itemBuilder: (context, index) => Column(
key: Key('drama-grid-skeleton-$index'),
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AspectRatio(
aspectRatio: AppDimens.cardCoverAspectRatio,
child: Container(
decoration: BoxDecoration(
color: AppColors.skeletonSurface,
borderRadius: BorderRadius.circular(
AppDimens.homeCardRadius,
),
),
),
),
const SizedBox(height: AppDimens.cardCoverTitleGap),
const _SkeletonBar(),
const SizedBox(height: AppDimens.skeletonTitleBarGap),
const FractionallySizedBox(
widthFactor: 0.62,
child: _SkeletonBar(),
),
],
final grid = _grid(constraints.maxWidth, constraints.maxHeight);
return AnimatedBuilder(
animation: _shimmer,
builder: (context, child) => ShaderMask(
blendMode: BlendMode.srcATop,
shaderCallback: (bounds) => _shader(bounds),
child: child,
),
child: grid,
);
},
);
}
Widget _grid(double width, double height) {
final itemWidth = _itemWidth(width);
final itemExtent = _itemExtent(itemWidth);
return GridView.builder(
physics: widget.physics,
padding: EdgeInsets.fromLTRB(
widget.horizontalPadding,
AppDimens.spaceMd,
widget.horizontalPadding,
AppDimens.spaceXl,
),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: widget.columns,
crossAxisSpacing: widget.crossAxisSpacing,
mainAxisSpacing: widget.mainAxisSpacing,
mainAxisExtent: itemExtent,
),
itemCount: _fillCount(height, itemExtent),
itemBuilder: (context, index) => _SkeletonCard(index: index),
);
}
double _itemWidth(double width) {
final gaps = widget.crossAxisSpacing * (widget.columns - 1);
return (width - widget.horizontalPadding * 2 - gaps) / widget.columns;
}
double _itemExtent(double itemWidth) {
return itemWidth / AppDimens.cardCoverAspectRatio +
AppDimens.skeletonBodyExtent;
}
int _fillCount(double height, double extent) {
if (!height.isFinite || extent <= 0) return widget.itemCount;
final rowHeight = extent + widget.mainAxisSpacing;
final rows = (height / rowHeight).ceil() + 1;
return math.max(widget.itemCount, rows * widget.columns);
}
Shader _shader(Rect bounds) {
final shift = _shimmerStart + _shimmerTravel * _shimmer.value;
return LinearGradient(
begin: Alignment(shift, -0.3),
end: Alignment(shift + 1, 0.3),
colors: const [
AppColors.skeletonSurface,
_shimmerHighlight,
AppColors.skeletonSurface,
],
).createShader(bounds);
}
}
class _SkeletonCard extends StatelessWidget {
const _SkeletonCard({required this.index});
final int index;
@override
Widget build(BuildContext context) {
return Column(
key: Key('drama-grid-skeleton-$index'),
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AspectRatio(
aspectRatio: AppDimens.cardCoverAspectRatio,
child: const _SkeletonBlock(),
),
const SizedBox(height: AppDimens.cardCoverTitleGap),
const _SkeletonBar(),
const SizedBox(height: AppDimens.skeletonTitleBarGap),
const FractionallySizedBox(
widthFactor: _subtitleWidthFactor,
child: _SkeletonBar(),
),
],
);
}
}
class _SkeletonBlock extends StatelessWidget {
const _SkeletonBlock();
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: BoxDecoration(
color: AppColors.skeletonSurface,
borderRadius: BorderRadius.circular(AppDimens.homeCardRadius),
),
);
}
}
class _SkeletonBar extends StatelessWidget {
+20 -1
View File
@@ -8,19 +8,27 @@ import '../theme/app_colors.dart';
import '../theme/app_dimens.dart';
import '../../l10n/app_localizations.dart';
/// 网络分页列表底部状态:仅保留加载圈与错误重试。
/// 网络分页列表底部:加载中、失败重试、没有下一页。
class PagedListFooter extends StatelessWidget {
const PagedListFooter({
super.key,
required this.loading,
required this.onRetry,
this.errorMessage,
this.hasMore = true,
this.hasItems = false,
});
final bool loading;
final String? errorMessage;
final Future<void> Function() onRetry;
/// 还有下一页时不显示结束文案。
final bool hasMore;
/// 列表为空时不显示「没有更多了」。
final bool hasItems;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
@@ -58,6 +66,17 @@ class PagedListFooter extends StatelessWidget {
),
);
}
if (hasItems && !hasMore) {
return Padding(
key: const Key('paged-list-no-more'),
padding: const EdgeInsets.symmetric(vertical: 16),
child: Text(
l10n.noMoreItems,
textAlign: TextAlign.center,
style: const TextStyle(color: AppColors.textHint, fontSize: 12),
),
);
}
return const SizedBox.shrink();
}
}
+17
View File
@@ -2,6 +2,7 @@
import 'package:flutter/material.dart';
import '../../l10n/app_localizations.dart';
import '../theme/app_colors.dart';
import '../theme/app_dimens.dart';
@@ -71,10 +72,14 @@ class PagedLoadFooter extends StatelessWidget {
super.key,
required this.loading,
required this.loadMoreKey,
this.hasMore = true,
this.hasItems = false,
});
final bool loading;
final Key loadMoreKey;
final bool hasMore;
final bool hasItems;
@override
Widget build(BuildContext context) {
@@ -93,6 +98,18 @@ class PagedLoadFooter extends StatelessWidget {
),
);
}
if (hasItems && !hasMore) {
final l10n = AppLocalizations.of(context)!;
return Padding(
key: const Key('paged-list-no-more'),
padding: const EdgeInsets.symmetric(vertical: 16),
child: Text(
l10n.noMoreItems,
textAlign: TextAlign.center,
style: const TextStyle(color: AppColors.textHint, fontSize: 12),
),
);
}
return const SizedBox.shrink();
}
}