diff --git a/lib/app.dart b/lib/app.dart index 75019d6..53133db 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -30,6 +30,7 @@ import 'core/data/repositories/favorite_repository.dart'; import 'core/data/repositories/interaction_repository.dart'; import 'core/data/repositories/notice_repository.dart'; import 'core/data/repositories/playback_repository.dart'; +import 'core/data/repositories/playlist_repository.dart'; import 'core/data/repositories/search_repository.dart'; import 'core/data/repositories/store_repository.dart'; import 'core/data/repositories/watch_history_repository.dart'; @@ -41,6 +42,7 @@ import 'core/data/services/download_controller.dart'; import 'core/data/services/favorite_controller.dart'; import 'core/data/services/interaction_controller.dart'; import 'core/data/services/locale_controller.dart'; +import 'core/data/services/playlist_controller.dart'; import 'core/data/services/event_tracker.dart'; import 'core/data/services/playback_position_cache.dart'; import 'core/data/services/playback_session.dart'; @@ -83,6 +85,7 @@ class LumaDramaApp extends StatefulWidget { this.commerceRepository, this.storeRepository, this.searchRepository, + this.playlistRepository, this.haptics = const FlutterHaptics(), }); @@ -103,6 +106,7 @@ class LumaDramaApp extends StatefulWidget { final CommerceRepository? commerceRepository; final StoreRepository? storeRepository; final SearchRepository? searchRepository; + final PlaylistRepository? playlistRepository; final Haptics haptics; @override @@ -126,8 +130,10 @@ class _LumaDramaAppState extends State { _sessionStore, ); + late final CatalogApi _catalogApi = CatalogApi(_apiClient); + late final CatalogRepository _catalogRepository = - widget.catalogRepository ?? HttpCatalogRepository(CatalogApi(_apiClient)); + widget.catalogRepository ?? HttpCatalogRepository(_catalogApi); late final CategoryRepository _categoryRepository = widget.categoryRepository ?? @@ -177,6 +183,13 @@ class _LumaDramaAppState extends State { late final SearchRepository _searchRepository = widget.searchRepository ?? HttpSearchRepository(CatalogApi(_apiClient)); + /// 测试注入了目录仓库时不打剧单接口,剧单页继续用本地分区。 + late final PlaylistRepository? _playlistRepository = + widget.playlistRepository ?? + (widget.catalogRepository == null + ? HttpPlaylistRepository(CatalogApi(_apiClient)) + : null); + late final StoreRepository _storeRepository = widget.storeRepository ?? HttpStoreRepository(CommerceApi(_apiClient)); @@ -239,14 +252,26 @@ class _LumaDramaAppState extends State { super.dispose(); } + CategoryController _createCategories() { + final controller = CategoryController(_categoryRepository); + if (widget.catalogRepository == null) { + controller.dramaPageLoader = CategoryApi(_apiClient).dramas; + } + controller.load(); + return controller; + } + @override Widget build(BuildContext context) { + final playlists = _playlistRepository; return MultiProvider( providers: [ Provider(create: (_) => PlaybackCoordinator()), Provider(create: (_) => EventTracker(_apiClient)), Provider(create: (_) => widget.playerFactory), Provider.value(value: _catalogRepository), + if (widget.catalogRepository == null) + Provider.value(value: _catalogApi), Provider.value(value: _playback), Provider.value(value: _playbackPositionCache), Provider.value(value: _watchHistory), @@ -260,11 +285,17 @@ class _LumaDramaAppState extends State { Provider.value(value: _searchRepository), Provider.value(value: widget.haptics), ChangeNotifierProvider( - create: (_) => CatalogController(_catalogRepository)..load(), - ), - ChangeNotifierProvider( - create: (_) => CategoryController(_categoryRepository)..load(), + create: (_) { + final catalog = CatalogController(_catalogRepository); + if (widget.catalogRepository != null) catalog.load(); + return catalog; + }, ), + ChangeNotifierProvider(create: (_) => _createCategories()), + if (playlists != null) + ChangeNotifierProvider( + create: (_) => PlaylistController(playlists), + ), ChangeNotifierProvider.value(value: _favoriteController), ChangeNotifierProvider.value(value: _downloadController), ChangeNotifierProvider.value(value: _walletController), diff --git a/lib/core/data/models/content_category.dart b/lib/core/data/models/content_category.dart index b93a308..af980a5 100644 --- a/lib/core/data/models/content_category.dart +++ b/lib/core/data/models/content_category.dart @@ -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 locales; /// 解析 `GET /app/categories` 的单项。 factory ContentCategory.fromJson(Map 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); } } diff --git a/lib/core/data/models/drama.dart b/lib/core/data/models/drama.dart index 4f889ae..82679fc 100644 --- a/lib/core/data/models/drama.dart +++ b/lib/core/data/models/drama.dart @@ -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 titleLocales; + final Map synopsisLocales; + final String tag; + final Map tagLocales; + final String tagColor; final String posterUrl; final List episodes; final String category; + final Map categoryLocales; + final List> types; final String genre; final int totalEpisodes; final int freeEpisodes; @@ -127,19 +144,26 @@ class Drama { /// 列表卡片:带一集可播预览(首页直接起播),完整剧集进详情再取。 factory Drama.fromCardJson(Map 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 ? [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 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 localizedTypeLabels(String languageCode) { + final labels = []; + 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> _typeLocales(Object? raw) { + if (raw is! List) return const []; + return raw.whereType().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 titleLocales; + final Map synopsisLocales; + final String tag; + final Map tagLocales; + final String category; + final Map categoryLocales; + final List> types; + final String genre; + + factory _DramaText.fromJson(Map 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 locales, Object? fallback, [List>? 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> 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 : ''; +} \ No newline at end of file diff --git a/lib/core/data/models/home_feed.dart b/lib/core/data/models/home_feed.dart index f16f468..2b0d661 100644 --- a/lib/core/data/models/home_feed.dart +++ b/lib/core/data/models/home_feed.dart @@ -48,7 +48,7 @@ class FeedSection { factory FeedSection.fromJson(Map json) { return FeedSection( code: json['code'] as String? ?? '', - title: json['title'] as String? ?? '', + title: _sectionTitle(json), dramas: (json['dramas'] as List? ?? const []) .whereType>() .map(Drama.fromCardJson) @@ -84,3 +84,12 @@ class FeedBanner { ); } } + +String _sectionTitle(Map json) { + final locales = json['titleLocales']; + if (locales is Map) { + final chinese = locales['zh']?.toString() ?? ''; + if (chinese.isNotEmpty) return chinese; + } + return json['title'] as String? ?? ''; +} diff --git a/lib/core/data/models/locale_text.dart b/lib/core/data/models/locale_text.dart new file mode 100644 index 0000000..38d4860 --- /dev/null +++ b/lib/core/data/models/locale_text.dart @@ -0,0 +1,16 @@ +// Author: fruit.com.cn + +/// 把接口里的语言表解析成字符串映射。 +Map 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 locales) { + final selected = locales[languageCode] ?? ''; + if (selected.isNotEmpty) return selected; + final chinese = locales['zh'] ?? ''; + if (chinese.isNotEmpty) return chinese; + return fallback; +} diff --git a/lib/core/data/models/playlist_detail.dart b/lib/core/data/models/playlist_detail.dart new file mode 100644 index 0000000..2446c27 --- /dev/null +++ b/lib/core/data/models/playlist_detail.dart @@ -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 locales; + + /// 中文描述,与 descriptionLocales 里的 zh 相同。 + final String description; + final Map descriptionLocales; + final int dramaCount; + final int pageIndex; + final int pageSize; + final List dramas; + + factory PlaylistDetail.fromJson(Map 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 _dramasOf(Object? rows) { + if (rows is! List) return const []; + return rows + .whereType() + .map((item) => Drama.fromCardJson(Map.from(item))) + .toList(growable: false); +} diff --git a/lib/core/data/models/watch_progress.dart b/lib/core/data/models/watch_progress.dart index cc61615..ae9fd71 100644 --- a/lib/core/data/models/watch_progress.dart +++ b/lib/core/data/models/watch_progress.dart @@ -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 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 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, diff --git a/lib/core/data/models/watch_record.dart b/lib/core/data/models/watch_record.dart index 41e93ee..021ddb1 100644 --- a/lib/core/data/models/watch_record.dart +++ b/lib/core/data/models/watch_record.dart @@ -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() ?? ''; } /// 服务端分页封面优先,详情封面作为回退。 diff --git a/lib/core/data/remote/catalog_api.dart b/lib/core/data/remote/catalog_api.dart index 8cfd60a..c57bcc1 100644 --- a/lib/core/data/remote/catalog_api.dart +++ b/lib/core/data/remote/catalog_api.dart @@ -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> 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 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> classifications() async { + return _categories(await _client.get('/app/categories')); + } + + /// 已启用类型,名称在 locales。 + Future> dramaCategories() async { + return _categories(await _client.get('/app/drama-categories')); + } + + /// 已启用标签,名称在 locales。 + Future> tags() async { + return _categories(await _client.get('/app/tags')); + } + + static const int libraryPageSize = 20; + + /// 全部短剧。同一组 id 多选为或,组与组之间为且。 + Future> libraryDramas({ + List categoryIds = const [], + List classificationIds = const [], + List 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 dramaDetail(String dramaId) async { return Drama.fromDetailJson( _asMap(await _client.get('/app/dramas/$dramaId')), ); } + List _categories(Object? data) { + if (data is! List) return const []; + return [ + for (final item in data) + if (item is Map) ContentCategory.fromJson(item), + ]; + } + Map _asMap(Object? data) { if (data is Map) return data; throw const ApiException(-1, '返回数据格式异常'); diff --git a/lib/core/data/remote/category_api.dart b/lib/core/data/remote/category_api.dart index 7c2d324..45c4f84 100644 --- a/lib/core/data/remote/category_api.dart +++ b/lib/core/data/remote/category_api.dart @@ -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) ContentCategory.fromJson(item), ]; } + + /// 分类下已上线短剧,pageIndex 从 1 开始。 + Future> 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); + } } diff --git a/lib/core/data/repositories/playlist_repository.dart b/lib/core/data/repositories/playlist_repository.dart new file mode 100644 index 0000000..9ac15e0 --- /dev/null +++ b/lib/core/data/repositories/playlist_repository.dart @@ -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> fetchPlaylists({ + int pageIndex = 1, + int pageSize = CatalogApi.playlistPageSize, + }); + + Future fetchPlaylist( + String id, { + int pageIndex = 1, + int pageSize = CatalogApi.playlistDramaPageSize, + }); +} + +class HttpPlaylistRepository implements PlaylistRepository { + HttpPlaylistRepository(this._api); + + final CatalogApi _api; + + @override + Future> fetchPlaylists({ + int pageIndex = 1, + int pageSize = CatalogApi.playlistPageSize, + }) => _api.playlists(pageIndex: pageIndex, pageSize: pageSize); + + @override + Future fetchPlaylist( + String id, { + int pageIndex = 1, + int pageSize = CatalogApi.playlistDramaPageSize, + }) => _api.playlist(id, pageIndex: pageIndex, pageSize: pageSize); +} diff --git a/lib/core/data/services/all_dramas_controller.dart b/lib/core/data/services/all_dramas_controller.dart new file mode 100644 index 0000000..ac8047b --- /dev/null +++ b/lib/core/data/services/all_dramas_controller.dart @@ -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> classifications(); + + /// 已启用类型。 + Future> categories(); + + /// 已启用标签。 + Future> tags(); + + /// 按当前筛选取一页短剧。 + Future> dramas({ + required List categoryIds, + required List classificationIds, + required List tagIds, + required int pageIndex, + }); +} + +/// 用现有目录接口实现全部短剧远端能力。 +class CatalogAllDramasRemote implements AllDramasRemote { + /// 绑定目录接口。 + const CatalogAllDramasRemote(this._api); + + final CatalogApi _api; + + @override + Future> classifications() => _api.classifications(); + + @override + Future> categories() => _api.dramaCategories(); + + @override + Future> tags() => _api.tags(); + + @override + Future> dramas({ + required List categoryIds, + required List classificationIds, + required List 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 classifications; + List categories; + List tags; + final Set selectedClassifications = {}; + final Set selectedCategories = {}; + final Set selectedTags = {}; + List 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 classifications = const [], + List categories = const [], + List tags = const [], + List dramas = const [], + }) { + return AllDramasController( + classifications: classifications, + categories: categories, + tags: tags, + dramas: dramas, + ); + } + + /// 从第一页重新拉取筛选项和短剧。没有接口时直接返回。 + Future 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 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 selected, String id) { + if (id.isEmpty) { + selected.clear(); + return; + } + if (!selected.add(id)) selected.remove(id); + } + + Future> _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; + } +} diff --git a/lib/core/data/services/category_controller.dart b/lib/core/data/services/category_controller.dart index d79702b..ecd6b87 100644 --- a/lib/core/data/services/category_controller.dart +++ b/lib/core/data/services/category_controller.dart @@ -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> Function( + String categoryId, { + int pageIndex, + int pageSize, + })? dramaPageLoader; + List categories = const []; + final Map _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 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 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 dramasOf(String categoryId) { final code = 'category-$categoryId'; @@ -30,19 +99,23 @@ class CategoryController extends ChangeNotifier { return const []; } - /// 加载分类与发现 feed;失败保留可重试错误态。 + /// 首页只拉分类,再拉第一个分类的短剧。发现页聚合留到没有短剧分页时再取。 Future load() async { loading = true; error = null; debugError = null; _notify(); try { - final results = await Future.wait([ - _repository.fetchCategories(), - _repository.fetchDiscoverFeed(), - ]); - categories = results[0] as List; - feed = results[1] as HomeFeed; + if (dramaPageLoader != null) { + categories = await _repository.fetchCategories(); + } else { + final results = await Future.wait([ + _repository.fetchCategories(), + _repository.fetchDiscoverFeed(), + ]); + categories = results[0] as List; + 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 dramas = const []; + int total = 0; + int pageIndex = 0; + bool loading = false; + bool loadingMore = false; + + bool get hasMore => dramas.length < total; +} diff --git a/lib/core/data/services/playlist_controller.dart b/lib/core/data/services/playlist_controller.dart new file mode 100644 index 0000000..4e5935c --- /dev/null +++ b/lib/core/data/services/playlist_controller.dart @@ -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 items = const []; + bool loading = false; + bool loadingMore = false; + bool loaded = false; + bool hasMore = false; + String? error; + int _pageIndex = 1; + bool _disposed = false; + + /// 从第一页重新拉取。失败保留错误态,成功后替换本地缓存。 + Future 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 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 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(); + } +} diff --git a/lib/core/formatting/localized_content.dart b/lib/core/formatting/localized_content.dart index 42b2eb7..564c604 100644 --- a/lib/core/formatting/localized_content.dart +++ b/lib/core/formatting/localized_content.dart @@ -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) => diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart index db89555..f06cda6 100644 --- a/lib/core/theme/app_colors.dart +++ b/lib/core/theme/app_colors.dart @@ -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); diff --git a/lib/core/widgets/drama_grid_skeleton.dart b/lib/core/widgets/drama_grid_skeleton.dart index 9fbc07c..96c4dee 100644 --- a/lib/core/widgets/drama_grid_skeleton.dart +++ b/lib/core/widgets/drama_grid_skeleton.dart @@ -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 createState() => _DramaGridSkeletonState(); +} + +class _DramaGridSkeletonState extends State + 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 { diff --git a/lib/core/widgets/paged_list_footer.dart b/lib/core/widgets/paged_list_footer.dart index ab47026..8f5fc71 100644 --- a/lib/core/widgets/paged_list_footer.dart +++ b/lib/core/widgets/paged_list_footer.dart @@ -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 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(); } } diff --git a/lib/core/widgets/paged_local_list.dart b/lib/core/widgets/paged_local_list.dart index 4565c7c..6a05e52 100644 --- a/lib/core/widgets/paged_local_list.dart +++ b/lib/core/widgets/paged_local_list.dart @@ -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(); } } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 2adaab3..bb5f156 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -219,6 +219,7 @@ "postComment": "Post", "commentPostSuccess": "Comment posted", "loadMore": "Load more", + "noMoreItems": "No more", "commentsLoadFailed": "Comments could not be loaded.", "commentPostFailed": "Could not post your comment.", "delete": "Delete", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 359a0f4..3a2cf44 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -193,6 +193,7 @@ "postComment": "Kirim", "commentPostSuccess": "Komentar terkirim", "loadMore": "Muat lebih banyak", + "noMoreItems": "Tidak ada lagi", "commentsLoadFailed": "Komentar tidak dapat dimuat.", "commentPostFailed": "Komentar tidak dapat dikirim.", "delete": "Hapus", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 3d3e44a..f9b1055 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -994,6 +994,12 @@ abstract class AppLocalizations { /// **'Load more'** String get loadMore; + /// No description provided for @noMoreItems. + /// + /// In en, this message translates to: + /// **'No more'** + String get noMoreItems; + /// No description provided for @commentsLoadFailed. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 51e1345..82b3a5d 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -486,6 +486,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get loadMore => 'Load more'; + @override + String get noMoreItems => 'No more'; + @override String get commentsLoadFailed => 'Comments could not be loaded.'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 25b53d9..18bef15 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -487,6 +487,9 @@ class AppLocalizationsId extends AppLocalizations { @override String get loadMore => 'Muat lebih banyak'; + @override + String get noMoreItems => 'Tidak ada lagi'; + @override String get commentsLoadFailed => 'Komentar tidak dapat dimuat.'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 4e82207..1acfb79 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -481,6 +481,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get loadMore => '加载更多'; + @override + String get noMoreItems => '没有更多了'; + @override String get commentsLoadFailed => '评论加载失败,请重试。'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index ee4d692..f6a4262 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -193,6 +193,7 @@ "postComment": "发布", "commentPostSuccess": "发表成功", "loadMore": "加载更多", + "noMoreItems": "没有更多了", "commentsLoadFailed": "评论加载失败,请重试。", "commentPostFailed": "评论发布失败,请重试。", "delete": "删除", diff --git a/lib/pages/account/account_deactivate_page.dart b/lib/pages/account/account_deactivate_page.dart index 73d4711..a2c9e8e 100644 --- a/lib/pages/account/account_deactivate_page.dart +++ b/lib/pages/account/account_deactivate_page.dart @@ -119,7 +119,10 @@ class _AccountDeactivatePageState extends State { controller: _reason, maxLength: AccountDeactivateLogic.maxReasonLength, maxLines: 4, + textAlignVertical: TextAlignVertical.top, decoration: InputDecoration( + alignLabelWithHint: true, + floatingLabelBehavior: FloatingLabelBehavior.auto, labelText: l10n.accountDeactivateReason, hintText: l10n.accountDeactivateReasonHint, ), diff --git a/lib/pages/auth/phone_auth_sheet.dart b/lib/pages/auth/phone_auth_sheet.dart index 9b96588..41d16e6 100644 --- a/lib/pages/auth/phone_auth_sheet.dart +++ b/lib/pages/auth/phone_auth_sheet.dart @@ -181,7 +181,7 @@ class _PhoneAuthSheetState extends State { ).showSnackBar(SnackBar(content: Text(l10n.authCodeSent))); _codeFocus.requestFocus(); } on ApiException catch (error) { - failureMessage = userFacingApiMessage(l10n, error.code, error.message); + failureMessage = error.message; } catch (_) { failureMessage = l10n.authNetworkError; } finally { @@ -229,7 +229,7 @@ class _PhoneAuthSheetState extends State { } if (mounted) Navigator.of(context).pop(true); } on ApiException catch (error) { - failureMessage = userFacingApiMessage(l10n, error.code, error.message); + failureMessage = error.message; } catch (_) { failureMessage = l10n.authNetworkError; } finally { diff --git a/lib/pages/discover/discover_view.dart b/lib/pages/discover/discover_view.dart index 6fe6d64..176be60 100644 --- a/lib/pages/discover/discover_view.dart +++ b/lib/pages/discover/discover_view.dart @@ -23,30 +23,18 @@ import '../../core/widgets/catalog_debug_info.dart'; import '../../core/widgets/catalog_endpoint_editor.dart'; import '../../core/widgets/empty_state.dart'; import '../../l10n/app_localizations.dart'; -import '../filter/filter_page.dart'; -import '../new_releases/new_releases_page.dart'; import '../player/open_player.dart'; -import '../ranking/ranking_page.dart'; -import '../reserve/reserve_page.dart'; import 'discover_binding.dart'; import 'discover_logic.dart'; import 'discover_types.dart'; +import 'widgets/discover_category_tab.dart'; import 'widgets/discover_header.dart'; import 'widgets/discover_search_results.dart'; +import 'widgets/discover_toolbar.dart'; import 'widgets/drama_grid.dart'; -const Color _filterIconColor = AppColors.blueSoft; -const Color _newIconColor = AppColors.blueSoft; -const double _toolbarHeight = 36; -const double _toolbarGap = 10; -const double _toolbarRadius = 10; const double _categoryTabHeight = 44; const double _categoryTabGap = 20; -const double _categorySelectedFontSize = 16; -const double _categoryFontSize = 15; -const double _categoryUnderlineWidth = 20; -const double _categoryUnderlineHeight = 3; -const double _categoryUnderlineStrokeWidth = 2; const double _stickyHeaderHeight = 44; const Duration _stickyHeaderTransition = Duration(milliseconds: 160); const Duration _categoryScrollDuration = Duration(milliseconds: 200); @@ -295,7 +283,7 @@ class _DiscoverPageState extends State { List _theaterBlocks(AppLocalizations l10n) { final dramas = theater.dramas; return [ - _toolbar(l10n), + _toolbar(), if (dramas.isEmpty) Padding( padding: const EdgeInsets.symmetric(vertical: 40), @@ -318,6 +306,16 @@ class _DiscoverPageState extends State { child: CircularProgressIndicator(strokeWidth: 2), ), ), + ) + else if (dramas.isNotEmpty && !theater.hasMore) + 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), + ), ), ]; } @@ -335,8 +333,7 @@ class _DiscoverPageState extends State { child: Row( mainAxisSize: MainAxisSize.min, children: [ - _categoryTab( - key: const Key('theater-category-all'), + DiscoverCategoryTab( anchorKey: _anchorFor('all'), id: 'all', label: l10n.all, @@ -345,8 +342,7 @@ class _DiscoverPageState extends State { ), for (final category in categories.categories) ...[ const SizedBox(width: _categoryTabGap), - _categoryTab( - key: Key('theater-category-${category.id}'), + DiscoverCategoryTab( anchorKey: _anchorFor(category.id), id: category.id, label: category.name, @@ -360,52 +356,6 @@ class _DiscoverPageState extends State { ); } - Widget _categoryTab({ - required Key key, - required GlobalKey anchorKey, - required String id, - required String label, - required bool selected, - required VoidCallback onTap, - }) { - return KeyedSubtree( - key: anchorKey, - child: GestureDetector( - key: key, - behavior: HitTestBehavior.opaque, - onTap: onTap, - child: Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - label, - maxLines: 1, - style: TextStyle( - color: selected ? AppColors.onSurface : AppColors.muted, - fontSize: selected - ? _categorySelectedFontSize - : _categoryFontSize, - fontWeight: selected ? FontWeight.w800 : FontWeight.w500, - ), - ), - const SizedBox(height: 4), - CustomPaint( - key: Key('theater-category-underline-$id'), - size: const Size( - _categoryUnderlineWidth, - _categoryUnderlineHeight, - ), - painter: CategoryUnderlinePainter( - color: selected ? AppColors.blue : AppColors.transparent, - ), - ), - ], - ), - ), - ); - } - GlobalKey _anchorFor(String id) { return _categoryAnchors.putIfAbsent(id, GlobalKey.new); } @@ -444,97 +394,8 @@ class _DiscoverPageState extends State { return true; } - Widget _toolbar(AppLocalizations l10n) { - return Padding( - padding: const EdgeInsets.fromLTRB(14, 2, 14, 12), - child: Row( - children: [ - Expanded( - child: _toolButton( - key: const Key('theater-toolbar-filter'), - icon: Icons.tune_rounded, - color: _filterIconColor, - label: l10n.filter, - onTap: () => _openPage(context, const FilterPage()), - ), - ), - const SizedBox(width: _toolbarGap), - Expanded( - child: _toolButton( - key: const Key('theater-toolbar-ranking'), - icon: Icons.local_fire_department_rounded, - color: AppColors.blue, - label: l10n.rankingTab, - onTap: () => _openPage(context, const RankingPage()), - ), - ), - const SizedBox(width: _toolbarGap), - Expanded( - child: _toolButton( - key: const Key('theater-toolbar-new'), - icon: Icons.play_circle_fill_rounded, - color: _newIconColor, - label: l10n.newDramas, - onTap: () => _openPage(context, const NewReleasesPage()), - ), - ), - const SizedBox(width: _toolbarGap), - Expanded( - child: _toolButton( - key: const Key('theater-toolbar-reserve'), - icon: Icons.calendar_month_rounded, - color: AppColors.blue, - label: l10n.reserve, - onTap: () => _openPage(context, const ReservePage()), - ), - ), - ], - ), - ); - } - - Widget _toolButton({ - required Key key, - required IconData icon, - required Color color, - required String label, - required VoidCallback onTap, - }) { - return Material( - color: AppColors.card, - borderRadius: BorderRadius.circular(_toolbarRadius), - child: InkWell( - key: key, - onTap: onTap, - borderRadius: BorderRadius.circular(_toolbarRadius), - child: Container( - height: _toolbarHeight, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(_toolbarRadius), - border: Border.all(color: AppColors.border), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(icon, size: 16, color: color), - const SizedBox(width: 4), - Flexible( - child: Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.onSurface, - fontSize: 13, - fontWeight: FontWeight.w600, - ), - ), - ), - ], - ), - ), - ), - ); + Widget _toolbar() { + return DiscoverToolbar(onOpen: (page) => _openPage(context, page)); } void _openPage(BuildContext context, Widget page) { @@ -601,27 +462,3 @@ class _DiscoverPageState extends State { AppRootTab.mine => DiscoverPageAction.mine, }; } - -/// 分类 tab 的弧形选中线。 -class CategoryUnderlinePainter extends CustomPainter { - const CategoryUnderlinePainter({required this.color}); - - final Color color; - - @override - void paint(Canvas canvas, Size size) { - final paint = Paint() - ..color = color - ..style = PaintingStyle.stroke - ..strokeWidth = _categoryUnderlineStrokeWidth - ..strokeCap = StrokeCap.round; - final path = Path() - ..moveTo(1, 1) - ..quadraticBezierTo(size.width / 2, size.height + 1, size.width - 1, 1); - canvas.drawPath(path, paint); - } - - @override - bool shouldRepaint(covariant CategoryUnderlinePainter oldDelegate) => - color != oldDelegate.color; -} diff --git a/lib/pages/discover/widgets/discover_category_tab.dart b/lib/pages/discover/widgets/discover_category_tab.dart new file mode 100644 index 0000000..e3d5275 --- /dev/null +++ b/lib/pages/discover/widgets/discover_category_tab.dart @@ -0,0 +1,89 @@ +// Author: fruit.com.cn +// 剧场顶部分类标签和选中下划线。 + +import 'package:flutter/material.dart'; + +import '../../../core/theme/app_colors.dart'; + +const double _selectedFontSize = 16; +const double _fontSize = 15; +const double _underlineWidth = 20; +const double _underlineHeight = 3; +const double _underlineStrokeWidth = 2; + +/// 一个可横向滚动的分类标签。 +class DiscoverCategoryTab extends StatelessWidget { + const DiscoverCategoryTab({ + super.key, + required this.id, + required this.anchorKey, + required this.label, + required this.selected, + required this.onTap, + }); + + final String id; + final GlobalKey anchorKey; + final String label; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return KeyedSubtree( + key: anchorKey, + child: GestureDetector( + key: Key('theater-category-$id'), + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + label, + maxLines: 1, + style: TextStyle( + color: selected ? AppColors.onSurface : AppColors.muted, + fontSize: selected ? _selectedFontSize : _fontSize, + fontWeight: selected ? FontWeight.w800 : FontWeight.w500, + ), + ), + const SizedBox(height: 4), + CustomPaint( + key: Key('theater-category-underline-$id'), + size: const Size(_underlineWidth, _underlineHeight), + painter: CategoryUnderlinePainter( + color: selected ? AppColors.blue : AppColors.transparent, + ), + ), + ], + ), + ), + ); + } +} + +/// 分类 tab 的弧形选中线。 +class CategoryUnderlinePainter extends CustomPainter { + const CategoryUnderlinePainter({required this.color}); + + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = color + ..style = PaintingStyle.stroke + ..strokeWidth = _underlineStrokeWidth + ..strokeCap = StrokeCap.round; + final path = Path() + ..moveTo(1, 1) + ..quadraticBezierTo(size.width / 2, size.height + 1, size.width - 1, 1); + canvas.drawPath(path, paint); + } + + @override + bool shouldRepaint(covariant CategoryUnderlinePainter oldDelegate) => + color != oldDelegate.color; +} diff --git a/lib/pages/discover/widgets/discover_toolbar.dart b/lib/pages/discover/widgets/discover_toolbar.dart new file mode 100644 index 0000000..da26705 --- /dev/null +++ b/lib/pages/discover/widgets/discover_toolbar.dart @@ -0,0 +1,119 @@ +// Author: fruit.com.cn +// 剧场页筛选、排行、新剧、预约四个入口。 + +import 'package:flutter/material.dart'; + +import '../../../core/theme/app_colors.dart'; +import '../../../l10n/app_localizations.dart'; +import '../../filter/filter_page.dart'; +import '../../new_releases/new_releases_page.dart'; +import '../../ranking/ranking_page.dart'; +import '../../reserve/reserve_page.dart'; + +const double _toolbarHeight = 36; +const double _toolbarGap = 10; +const double _toolbarRadius = 10; +const Color _filterIconColor = AppColors.blueSoft; +const Color _newIconColor = AppColors.blueSoft; + +/// 剧场工具条。点按后由调用方打开对应页面。 +class DiscoverToolbar extends StatelessWidget { + const DiscoverToolbar({super.key, required this.onOpen}); + + final void Function(Widget page) onOpen; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return Padding( + padding: const EdgeInsets.fromLTRB(14, 2, 14, 12), + child: Row( + children: [ + Expanded( + child: _button( + key: const Key('theater-toolbar-filter'), + icon: Icons.tune_rounded, + color: _filterIconColor, + label: l10n.filter, + onTap: () => onOpen(const FilterPage()), + ), + ), + const SizedBox(width: _toolbarGap), + Expanded( + child: _button( + key: const Key('theater-toolbar-ranking'), + icon: Icons.local_fire_department_rounded, + color: AppColors.blue, + label: l10n.rankingTab, + onTap: () => onOpen(const RankingPage()), + ), + ), + const SizedBox(width: _toolbarGap), + Expanded( + child: _button( + key: const Key('theater-toolbar-new'), + icon: Icons.play_circle_fill_rounded, + color: _newIconColor, + label: l10n.newDramas, + onTap: () => onOpen(const NewReleasesPage()), + ), + ), + const SizedBox(width: _toolbarGap), + Expanded( + child: _button( + key: const Key('theater-toolbar-reserve'), + icon: Icons.calendar_month_rounded, + color: AppColors.blue, + label: l10n.reserve, + onTap: () => onOpen(const ReservePage()), + ), + ), + ], + ), + ); + } + + Widget _button({ + required Key key, + required IconData icon, + required Color color, + required String label, + required VoidCallback onTap, + }) { + return Material( + color: AppColors.card, + borderRadius: BorderRadius.circular(_toolbarRadius), + child: InkWell( + key: key, + onTap: onTap, + borderRadius: BorderRadius.circular(_toolbarRadius), + child: Container( + height: _toolbarHeight, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(_toolbarRadius), + border: Border.all(color: AppColors.border), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: 16, color: color), + const SizedBox(width: 4), + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/pages/discover/widgets/drama_grid.dart b/lib/pages/discover/widgets/drama_grid.dart index a76d7eb..17a2c12 100644 --- a/lib/pages/discover/widgets/drama_grid.dart +++ b/lib/pages/discover/widgets/drama_grid.dart @@ -96,9 +96,9 @@ class DramaGridCard extends StatelessWidget { children: [ _cover(), const SizedBox(height: _titleGap), - _title(), + _title(context), const SizedBox(height: _tagsGap), - _tags(), + _tags(context), ], ), ); @@ -151,9 +151,9 @@ class DramaGridCard extends StatelessWidget { ); } - Widget _title() { + Widget _title(BuildContext context) { return Text( - drama.title, + drama.localizedTitle(Localizations.localeOf(context).languageCode), key: Key('home-drama-title-${drama.id}'), maxLines: 2, overflow: TextOverflow.ellipsis, @@ -166,8 +166,8 @@ class DramaGridCard extends StatelessWidget { ); } - Widget _tags() { - final tags = _tagValues(); + Widget _tags(BuildContext context) { + final tags = _tagValues(context); if (tags.isEmpty) return const SizedBox.shrink(); return Wrap( spacing: 6, @@ -193,7 +193,9 @@ class DramaGridCard extends StatelessWidget { ); } - List _tagValues() { + List _tagValues(BuildContext context) { + final localized = drama.localizedTypeLabels(Localizations.localeOf(context).languageCode); + if (localized.isNotEmpty) return localized.take(3).toList(growable: false); final result = []; for (final value in [drama.genre, drama.category]) { for (final part in value.split(_tagSplitter)) { diff --git a/lib/pages/discover/widgets/search_result_card.dart b/lib/pages/discover/widgets/search_result_card.dart index ad6043f..bf6d0e1 100644 --- a/lib/pages/discover/widgets/search_result_card.dart +++ b/lib/pages/discover/widgets/search_result_card.dart @@ -16,7 +16,7 @@ class SearchResultCard extends StatelessWidget { @override Widget build(BuildContext context) { - final tags = _tags(); + final tags = _tags(context); return Material( key: Key('search-result-card-${drama.id}'), color: AppColors.transparent, @@ -60,7 +60,7 @@ class SearchResultCard extends StatelessWidget { ), const SizedBox(height: AppDimens.searchResultTitleGap), Text( - drama.title, + drama.localizedTitle(Localizations.localeOf(context).languageCode), key: Key('search-result-title-${drama.id}'), maxLines: 2, overflow: TextOverflow.ellipsis, @@ -92,7 +92,9 @@ class SearchResultCard extends StatelessWidget { ); } - List _tags() { + List _tags(BuildContext context) { + final localized = drama.localizedTypeLabels(Localizations.localeOf(context).languageCode); + if (localized.isNotEmpty) return localized.take(3).toList(growable: false); final seen = {}; return [drama.genre, drama.category] .expand((value) => value.split(RegExp(r'[,/、·]'))) diff --git a/lib/pages/drama_detail/drama_detail_view.dart b/lib/pages/drama_detail/drama_detail_view.dart index eaedc8e..78b038e 100644 --- a/lib/pages/drama_detail/drama_detail_view.dart +++ b/lib/pages/drama_detail/drama_detail_view.dart @@ -163,8 +163,8 @@ class _DramaDetailPageState extends State { synopsis: localizedDramaSynopsis(l10n, drama), ), ), - if (_tagValues(drama).isNotEmpty) - SliverToBoxAdapter(child: DramaTags(tags: _tagValues(drama))), + if (_tagValues(context, drama).isNotEmpty) + SliverToBoxAdapter(child: DramaTags(tags: _tagValues(context, drama))), SliverToBoxAdapter(child: _episodes(l10n, drama, continueIndex)), const SliverToBoxAdapter(child: SizedBox(height: 24)), ], @@ -442,13 +442,18 @@ class _DramaDetailPageState extends State { return record.fraction; } - List _tagValues(Drama drama) { + List _tagValues(BuildContext context, Drama drama) { + final localized = drama.localizedTypeLabels(Localizations.localeOf(context).languageCode); final result = []; - for (final source in [drama.category, drama.genre]) { - for (final part in source.split(_tagSplitter)) { - final value = part.trim(); - if (value.isNotEmpty && !result.contains(value)) result.add(value); - if (result.length == _maxTags) return result; + if (localized.isNotEmpty) { + result.addAll(localized.take(_maxTags)); + } else { + for (final source in [drama.category, drama.genre]) { + for (final part in source.split(_tagSplitter)) { + final value = part.trim(); + if (value.isNotEmpty && !result.contains(value)) result.add(value); + if (result.length == _maxTags) return result; + } } } final language = drama.language.trim(); diff --git a/lib/pages/favorites/favorites_page.dart b/lib/pages/favorites/favorites_page.dart index bfadfe5..5e0d871 100644 --- a/lib/pages/favorites/favorites_page.dart +++ b/lib/pages/favorites/favorites_page.dart @@ -176,6 +176,8 @@ class _FavoritesBody extends StatelessWidget { ], PagedListFooter( loading: favorites.loadingMore, + hasMore: favorites.hasMore, + hasItems: favorites.cards.isNotEmpty, errorMessage: favorites.loadMoreError == null ? null : errorText(l10n, favorites.loadMoreError, l10n.favoritesError), @@ -265,8 +267,9 @@ class _FavoriteCard extends StatelessWidget { @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; - final tags = _tags(); - final synopsis = drama.synopsis.trim(); + final languageCode = Localizations.localeOf(context).languageCode; + final tags = _tags(languageCode); + final synopsis = drama.localizedSynopsis(languageCode).trim(); return Container( key: ValueKey('favorite-card-${drama.id}'), decoration: BoxDecoration( @@ -309,7 +312,7 @@ class _FavoriteCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - drama.title, + drama.localizedTitle(languageCode), key: ValueKey('favorite-title-${drama.id}'), maxLines: 2, overflow: TextOverflow.ellipsis, @@ -444,7 +447,9 @@ class _FavoriteCard extends StatelessWidget { ); } - List _tags() { + List _tags(String languageCode) { + final localized = drama.localizedTypeLabels(languageCode); + if (localized.isNotEmpty) return localized.take(_maxTags).toList(); final values = []; for (final source in [drama.genre, drama.category]) { for (final part in source.split(_tagSplitter)) { diff --git a/lib/pages/following/following_favorites_grid.dart b/lib/pages/following/following_favorites_grid.dart index 11211c6..5839090 100644 --- a/lib/pages/following/following_favorites_grid.dart +++ b/lib/pages/following/following_favorites_grid.dart @@ -97,7 +97,7 @@ class FollowingFavoritesGrid extends StatelessWidget { final episode = drama.episodes.isEmpty ? 1 : drama.episodes.first.number; return FollowingGridCard( dramaId: drama.id, - title: drama.title, + title: drama.localizedTitle(Localizations.localeOf(context).languageCode), posterUrl: drama.posterUrl, viewCount: drama.viewCount, editing: isEditing, diff --git a/lib/pages/following/following_page.dart b/lib/pages/following/following_page.dart index 4154daa..7ba9f2d 100644 --- a/lib/pages/following/following_page.dart +++ b/lib/pages/following/following_page.dart @@ -402,7 +402,11 @@ class _HistoryList extends StatelessWidget { return FollowingHistoryCard( dramaId: record.dramaId, - title: _historyTitle(l10n, record), + title: _historyTitle( + l10n, + record, + Localizations.localeOf(context).languageCode, + ), posterUrl: record.displayCover, viewCount: record.progress.viewCount ?? @@ -433,10 +437,13 @@ class _HistoryList extends StatelessWidget { } } -String _historyTitle(AppLocalizations l10n, WatchRecord record) { - return record.displayTitle.isEmpty - ? l10n.episodeLabel(record.episodeNumber) - : record.displayTitle; +String _historyTitle( + AppLocalizations l10n, + WatchRecord record, + String languageCode, +) { + final title = record.localizedDisplayTitle(languageCode); + return title.isEmpty ? l10n.episodeLabel(record.episodeNumber) : title; } String _historySubtitle(AppLocalizations l10n, WatchRecord record) { diff --git a/lib/pages/following/following_paged_grid.dart b/lib/pages/following/following_paged_grid.dart index 119b900..5ea8167 100644 --- a/lib/pages/following/following_paged_grid.dart +++ b/lib/pages/following/following_paged_grid.dart @@ -130,6 +130,8 @@ class FollowingPagedGrid extends StatelessWidget { SliverToBoxAdapter( child: PagedListFooter( loading: loadingMore, + hasMore: hasMore, + hasItems: hasItems, errorMessage: loadMoreError, onRetry: onLoadMore, ), diff --git a/lib/pages/home/home_view.dart b/lib/pages/home/home_view.dart index ecc5ad3..24c26c6 100644 --- a/lib/pages/home/home_view.dart +++ b/lib/pages/home/home_view.dart @@ -7,6 +7,7 @@ import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../../core/config/app_config.dart'; +import '../../core/data/remote/catalog_api.dart'; import '../../core/data/services/home_feed_controller.dart'; import '../../core/data/services/catalog_controller.dart'; import '../../core/data/services/category_controller.dart'; @@ -209,6 +210,7 @@ class _HomeTabsState extends State<_HomeTabs> { Widget _recommend(BuildContext context) { return RecommendPage( feedController: widget.feedController, + catalogApi: _catalogApi(context), active: logic.tab == 1, videoOnly: logic.videoImmersive, onImmersiveChanged: logic.onImmersiveChanged, @@ -216,6 +218,14 @@ class _HomeTabsState extends State<_HomeTabs> { ); } + CatalogApi? _catalogApi(BuildContext context) { + try { + return context.read(); + } on ProviderNotFoundException { + return null; + } + } + void _openSearch(BuildContext context) { Navigator.of( context, diff --git a/lib/pages/home/widgets/home_category_dramas.dart b/lib/pages/home/widgets/home_category_dramas.dart new file mode 100644 index 0000000..8abcbb7 --- /dev/null +++ b/lib/pages/home/widgets/home_category_dramas.dart @@ -0,0 +1,178 @@ +// Author: fruit.com.cn + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../../core/data/models/drama.dart'; +import '../../../core/data/services/category_controller.dart'; +import '../../../core/theme/app_dimens.dart'; +import '../../../core/widgets/app_pull_to_refresh.dart'; +import '../../../core/widgets/drama_grid_skeleton.dart'; +import '../../../core/widgets/empty_state.dart'; +import '../../../core/widgets/paged_list_footer.dart'; +import '../../../l10n/app_localizations.dart'; +import 'home_feed_card.dart'; + +const double _loadMoreThreshold = 240; + +/// 首页分类下的短剧:按分类分页,滑到底加载下一页。 +class HomeCategoryDramas extends StatefulWidget { + const HomeCategoryDramas({ + super.key, + required this.onOpenDrama, + this.lockedCategoryId, + }); + + final ValueChanged onOpenDrama; + final String? lockedCategoryId; + + @override + State createState() => _HomeCategoryDramasState(); +} + +class _HomeCategoryDramasState extends State { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _ensureOpened()); + } + + @override + void didUpdateWidget(HomeCategoryDramas oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.lockedCategoryId != widget.lockedCategoryId) { + _ensureOpened(); + } + } + + void _ensureOpened() { + if (!mounted) return; + final controller = context.read(); + final locked = widget.lockedCategoryId; + if (locked == null || locked.isEmpty) return; + final slot = controller.slotOf(locked); + if (slot.loading || slot.pageIndex > 0) return; + unawaited(controller.openCategory(locked)); + } + + bool _onScroll(ScrollNotification notification) { + final categoryId = widget.lockedCategoryId; + if (categoryId == null || categoryId.isEmpty) return false; + if (notification.depth != 0) return false; + if (notification.metrics.extentAfter > _loadMoreThreshold) return false; + if (notification is! ScrollUpdateNotification && + notification is! ScrollEndNotification && + notification is! OverscrollNotification) { + return false; + } + unawaited(context.read().loadMore(categoryId)); + return false; + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final controller = context.watch(); + final categoryId = widget.lockedCategoryId ?? ''; + final slot = categoryId.isEmpty ? null : controller.slotOf(categoryId); + final dramas = slot?.dramas ?? const []; + return Column( + children: [ + Expanded( + child: NotificationListener( + onNotification: _onScroll, + child: AppPullToRefresh( + onRefresh: () async { + if (categoryId.isNotEmpty) { + await controller.openCategory(categoryId); + } + }, + child: _body(context, l10n, controller, categoryId, dramas), + ), + ), + ), + ], + ); + } + + Widget _body( + BuildContext context, + AppLocalizations l10n, + CategoryController controller, + String categoryId, + List dramas, + ) { + final slot = categoryId.isEmpty ? null : controller.slotOf(categoryId); + if (slot != null && slot.loading && dramas.isEmpty) { + return const DramaGridSkeleton( + key: Key('home-grid-skeleton'), + columns: 3, + horizontalPadding: AppDimens.feedPageHorizontal, + crossAxisSpacing: AppDimens.homeGridGap, + mainAxisSpacing: AppDimens.homeGridRowGap, + ); + } + if (dramas.isEmpty) { + return EmptyState( + key: const Key('home-grid-empty'), + icon: Icons.movie_filter_outlined, + title: l10n.emptyContent, + ); + } + return LayoutBuilder( + builder: (context, constraints) { + final itemWidth = + (constraints.maxWidth - + AppDimens.feedPageHorizontal * 2 - + AppDimens.homeGridGap * 2) / + 3; + final itemExtent = + itemWidth / AppDimens.cardCoverAspectRatio + + AppDimens.homeCardBodyExtent; + return CustomScrollView( + key: const Key('home-grid-scroll'), + physics: AppPullToRefresh.physics, + slivers: [ + SliverPadding( + padding: const EdgeInsets.symmetric( + horizontal: AppDimens.feedPageHorizontal, + ), + sliver: SliverGrid( + key: const Key('home-grid-cards'), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + crossAxisSpacing: AppDimens.homeGridGap, + mainAxisSpacing: AppDimens.homeGridRowGap, + mainAxisExtent: itemExtent, + ), + delegate: SliverChildBuilderDelegate( + (context, index) => HomeFeedCard( + drama: dramas[index], + onTap: () => widget.onOpenDrama(dramas[index]), + ), + childCount: dramas.length, + ), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.only(bottom: AppDimens.spaceXl), + child: PagedListFooter( + loading: slot != null && slot.loadingMore, + hasMore: slot?.hasMore ?? false, + hasItems: dramas.isNotEmpty, + errorMessage: null, + onRetry: () => context.read().loadMore( + categoryId, + ), + ), + ), + ), + ], + ); + }, + ); + } +} diff --git a/lib/pages/home/widgets/home_drama_grid.dart b/lib/pages/home/widgets/home_drama_grid.dart index c923237..b1f0980 100644 --- a/lib/pages/home/widgets/home_drama_grid.dart +++ b/lib/pages/home/widgets/home_drama_grid.dart @@ -99,9 +99,9 @@ class HomeDramaCard extends StatelessWidget { children: [ _cover(), const SizedBox(height: _coverTitleGap), - _title(), + _title(context), const SizedBox(height: _lineGap), - _genre(), + _genre(context), const SizedBox(height: _lineGap), _metadata(), ], @@ -211,9 +211,9 @@ class HomeDramaCard extends StatelessWidget { ); } - Widget _title() { + Widget _title(BuildContext context) { return Text( - drama.title, + drama.localizedTitle(Localizations.localeOf(context).languageCode), key: Key('home-drama-title-${drama.id}'), maxLines: 1, overflow: TextOverflow.ellipsis, @@ -225,8 +225,11 @@ class HomeDramaCard extends StatelessWidget { ); } - Widget _genre() { - final genre = drama.genre.isEmpty ? drama.category : drama.genre; + Widget _genre(BuildContext context) { + final labels = drama.localizedTypeLabels(Localizations.localeOf(context).languageCode); + final genre = labels.isEmpty + ? (drama.genre.isEmpty ? drama.category : drama.genre) + : labels.join(' / '); return Text( genre.isEmpty ? l10n.videoPending : genre, key: Key('home-drama-genre-${drama.id}'), diff --git a/lib/pages/home/widgets/home_feed_card.dart b/lib/pages/home/widgets/home_feed_card.dart index a5ad5bd..fa002d9 100644 --- a/lib/pages/home/widgets/home_feed_card.dart +++ b/lib/pages/home/widgets/home_feed_card.dart @@ -8,28 +8,21 @@ import '../../../core/theme/app_dimens.dart'; import '../../../core/widgets/cover_play_count.dart'; import '../../../core/widgets/drama_cover_badge_ribbon.dart'; import '../../../core/widgets/remote_image.dart'; -import '../../../l10n/app_localizations.dart'; -import 'home_drama_classification.dart'; final RegExp _tagSplitter = RegExp(r'[,/、·]'); -enum HomeDramaBadge { hot, newRelease, anime } - class HomeFeedCard extends StatelessWidget { const HomeFeedCard({ super.key, required this.drama, required this.onTap, - this.now, }); final Drama drama; final VoidCallback onTap; - final DateTime? now; @override Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context)!; return Material( key: Key('home-feed-card-${drama.id}'), color: AppColors.transparent, @@ -40,7 +33,7 @@ class HomeFeedCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.start, children: [ - _cover(l10n), + _cover(context), Expanded( child: Padding( padding: const EdgeInsets.only( @@ -51,7 +44,7 @@ class HomeFeedCard extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.start, children: [ Text( - drama.title, + drama.localizedTitle(Localizations.localeOf(context).languageCode), key: Key('home-feed-title-${drama.id}'), maxLines: 2, overflow: TextOverflow.ellipsis, @@ -64,7 +57,7 @@ class HomeFeedCard extends StatelessWidget { ), ), const SizedBox(height: AppDimens.cardTitleTagGap), - _tags(), + _tags(context), ], ), ), @@ -75,8 +68,8 @@ class HomeFeedCard extends StatelessWidget { ); } - Widget _cover(AppLocalizations l10n) { - final badge = _badge(); + Widget _cover(BuildContext context) { + final label = drama.localizedTag(Localizations.localeOf(context).languageCode); return ClipRRect( key: Key('home-feed-cover-clip-${drama.id}'), borderRadius: BorderRadius.circular(AppDimens.cardCoverRadius), @@ -92,14 +85,14 @@ class HomeFeedCard extends StatelessWidget { showFallbackIcon: true, fallbackColor: AppColors.contentSurface, ), - if (badge != null) + if (label.isNotEmpty) Positioned( top: 0, right: 0, child: DramaCoverBadgeRibbon( - label: _badgeLabel(l10n, badge), - color: _badgeColor(badge), - keyPrefix: '${drama.id}-${badge.name}', + label: label, + color: drama.tagRibbonColor(AppColors.badgeHot), + keyPrefix: '${drama.id}-tag', ), ), const Positioned( @@ -122,35 +115,8 @@ class HomeFeedCard extends StatelessWidget { ); } - HomeDramaBadge? _badge() { - if (drama.dramaType.toUpperCase() == 'ANIME') { - return HomeDramaBadge.anime; - } - if (isNewDrama(drama, now: now)) return HomeDramaBadge.newRelease; - if (isHotDrama(drama)) { - return HomeDramaBadge.hot; - } - return null; - } - - String _badgeLabel(AppLocalizations l10n, HomeDramaBadge badge) { - return switch (badge) { - HomeDramaBadge.hot => l10n.homeBadgeHot, - HomeDramaBadge.newRelease => l10n.homeBadgeNew, - HomeDramaBadge.anime => l10n.homeBadgeAnime, - }; - } - - Color _badgeColor(HomeDramaBadge badge) { - return switch (badge) { - HomeDramaBadge.hot => AppColors.badgeHot, - HomeDramaBadge.newRelease => AppColors.badgeNew, - HomeDramaBadge.anime => AppColors.badgeAnime, - }; - } - - Widget _tags() { - final tags = _cardTags(); + Widget _tags(BuildContext context) { + final tags = _cardTags(context); if (tags.isEmpty) return const SizedBox.shrink(); return Text( tags.join(' | '), @@ -166,7 +132,9 @@ class HomeFeedCard extends StatelessWidget { ); } - List _cardTags() { + List _cardTags(BuildContext context) { + final localized = drama.localizedTypeLabels(Localizations.localeOf(context).languageCode); + if (localized.isNotEmpty) return localized.take(3).toList(growable: false); final seen = {}; return [drama.genre, drama.category] .expand((value) => value.split(_tagSplitter)) diff --git a/lib/pages/home/widgets/home_feed_grid.dart b/lib/pages/home/widgets/home_feed_grid.dart index 880cf89..d35b8ed 100644 --- a/lib/pages/home/widgets/home_feed_grid.dart +++ b/lib/pages/home/widgets/home_feed_grid.dart @@ -14,20 +14,16 @@ import '../../../core/data/services/home_feed_controller.dart'; import '../../../core/data/services/wallet_controller.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; -import '../../../core/theme/app_theme.dart'; import '../../../core/widgets/app_pull_to_refresh.dart'; import '../../../core/widgets/drama_grid_skeleton.dart'; import '../../../core/widgets/empty_state.dart'; import '../../../core/widgets/paged_list_footer.dart'; import '../../../l10n/app_localizations.dart'; -import '../../ranking/ranking_page.dart'; -import 'home_drama_classification.dart'; +import 'home_category_dramas.dart'; import 'home_feed_card.dart'; import 'home_grid_header.dart'; const double _loadMoreThreshold = 240; -const List _costumeAliases = ['古装', 'costume', 'period']; -const List _communityAliases = ['社群', 'community', 'komunitas']; class HomeFeedGrid extends StatefulWidget { const HomeFeedGrid({ @@ -46,21 +42,15 @@ class HomeFeedGrid extends StatefulWidget { } class _HomeFeedGridState extends State { - HomePrimaryTab _selectedPrimary = HomePrimaryTab.featured; + int _sectionIndex = 0; final PageController _pageController = PageController(); - final Map _scrollControllers = { - for (final tab in HomePrimaryTab.values) tab: ScrollController(), - }; - final Map _selectedTags = { - for (final tab in HomePrimaryTab.values) tab: null, - }; @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && _pageController.hasClients) { - _pageController.jumpToPage(_selectedPrimary.index); + _pageController.jumpToPage(_sectionIndex); } }); } @@ -68,9 +58,6 @@ class _HomeFeedGridState extends State { @override void dispose() { _pageController.dispose(); - for (final controller in _scrollControllers.values) { - controller.dispose(); - } super.dispose(); } @@ -81,17 +68,16 @@ class _HomeFeedGridState extends State { final catalog = context.watch(); final auth = context.watch(); final wallet = context.watch(); - final shortcuts = _categoryShortcuts(categories, l10n); + final shortcuts = _categoryShortcuts( + categories, + Localizations.localeOf(context).languageCode, + ); final walletBadge = auth.isSignedIn && wallet.snapshot != null ? wallet.balance : null; - final ranking = _selectedPrimary == HomePrimaryTab.ranking; return DecoratedBox( key: const Key('home-grid'), - decoration: BoxDecoration( - color: ranking ? null : AppColors.appBackground, - gradient: ranking ? AppTheme.pageGradient : null, - ), + decoration: const BoxDecoration(color: AppColors.appBackground), child: SafeArea( bottom: false, child: Column( @@ -100,8 +86,8 @@ class _HomeFeedGridState extends State { HomeGridHeader( callbacks: widget.callbacks, categories: shortcuts, - selectedPrimary: _selectedPrimary, - onPrimarySelected: _selectPrimary, + selectedIndex: _sectionIndex, + onSectionSelected: _selectSection, walletBadge: walletBadge, ), Expanded( @@ -110,22 +96,29 @@ class _HomeFeedGridState extends State { builder: (context, _) => PageView.builder( key: const Key('home-primary-page-view'), controller: _pageController, - itemCount: HomePrimaryTab.values.length, - onPageChanged: (index) => setState( - () => _selectedPrimary = HomePrimaryTab.values[index], - ), - itemBuilder: (context, index) => _KeepAlivePage( - key: PageStorageKey( - 'home-primary-page-${HomePrimaryTab.values[index].name}', - ), - child: _tabGrid( - context, - l10n, - tab: HomePrimaryTab.values[index], - catalog: catalog, - shortcuts: shortcuts, - ), - ), + itemCount: shortcuts.isEmpty ? 1 : shortcuts.length, + onPageChanged: (index) => + setState(() => _sectionIndex = index), + itemBuilder: (context, index) { + if (shortcuts.isEmpty) { + return _categoryPlaceholder( + l10n, + context.watch(), + ); + } + final category = shortcuts[index]; + return _KeepAlivePage( + key: PageStorageKey( + 'home-primary-page-${category.categoryId}', + ), + child: _tabGrid( + context, + l10n, + category: category, + catalog: catalog, + ), + ); + }, ), ), ), @@ -135,28 +128,54 @@ class _HomeFeedGridState extends State { ); } - void _selectPrimary(HomePrimaryTab tab) { - if (tab == _selectedPrimary) return; + void _selectSection(int index) { + if (index == _sectionIndex) return; if (!_pageController.hasClients) { - setState(() => _selectedPrimary = tab); + setState(() => _sectionIndex = index); return; } unawaited( _pageController.animateToPage( - tab.index, + index, duration: AppDimens.homePrimaryTabAnimationDuration, curve: Curves.easeOut, ), ); } + Widget _categoryPlaceholder( + AppLocalizations l10n, + CategoryController controller, + ) { + if (controller.loading) { + return const DramaGridSkeleton( + key: Key('home-grid-skeleton'), + columns: 3, + horizontalPadding: AppDimens.feedPageHorizontal, + crossAxisSpacing: AppDimens.homeGridGap, + mainAxisSpacing: AppDimens.homeGridRowGap, + ); + } + return EmptyState( + key: const Key('home-grid-empty'), + icon: Icons.movie_filter_outlined, + title: l10n.emptyContent, + ); + } + Widget _tabGrid( BuildContext context, AppLocalizations l10n, { - required HomePrimaryTab tab, + required HomeGridCategoryShortcut category, required CatalogController catalog, - required List shortcuts, }) { + final controller = context.read(); + if (controller.dramaPageLoader != null && category.categoryId.isNotEmpty) { + return HomeCategoryDramas( + lockedCategoryId: category.categoryId, + onOpenDrama: widget.onOpenDrama, + ); + } if (catalog.error != null && widget.feedController.dramas.isEmpty) { return _catalogErrorState(l10n, catalog); } @@ -170,11 +189,7 @@ class _HomeFeedGridState extends State { mainAxisSpacing: AppDimens.homeGridRowGap, ); } - if (tab == HomePrimaryTab.ranking) { - return const RankingPage(embedded: true); - } - final tag = _selectedTags[tab]; - final dramas = _dramasFor(tab, tag, shortcuts); + final dramas = _dramasFor(category); if (widget.feedController.loadingFirstPage && dramas.isEmpty) { return const DramaGridSkeleton( key: Key('home-grid-skeleton'), @@ -201,7 +216,7 @@ class _HomeFeedGridState extends State { onRefresh: _refresh, child: CustomScrollView( key: const Key('home-grid-scroll'), - controller: _scrollControllers[tab], + controller: null, physics: AppPullToRefresh.physics, slivers: [ if (dramas.isEmpty) @@ -237,6 +252,8 @@ class _HomeFeedGridState extends State { padding: const EdgeInsets.only(bottom: AppDimens.spaceXl), child: PagedListFooter( loading: widget.feedController.loadingMore, + hasMore: widget.feedController.hasMore, + hasItems: true, errorMessage: widget.feedController.lastError == null ? null : l10n.catalogError, @@ -289,75 +306,31 @@ class _HomeFeedGridState extends State { List _categoryShortcuts( List categories, - AppLocalizations l10n, + String languageCode, ) { - final costume = _findCategory(categories, _costumeAliases); - final community = _findCategory(categories, _communityAliases); return [ - HomeGridCategoryShortcut( - label: l10n.homeSectionCostume, - categoryName: costume?.name ?? '', - tab: HomePrimaryTab.costume, - ), - HomeGridCategoryShortcut( - label: l10n.homeSectionCommunity, - categoryName: community?.name ?? '', - tab: HomePrimaryTab.community, - ), + for (final category in categories) + HomeGridCategoryShortcut( + label: category.localizedName(languageCode), + categoryName: category.name, + categoryId: category.id, + tab: HomePrimaryTab.categories, + ), ]; } - ContentCategory? _findCategory( - List categories, - List aliases, - ) { - for (final category in categories) { - final name = category.name.toLowerCase(); - if (aliases.any(name.contains)) return category; - } - return null; - } - - List _dramasFor( - HomePrimaryTab tab, - String? tag, - List shortcuts, - ) { - Iterable source = widget.feedController.dramas; - if (tab == HomePrimaryTab.costume || tab == HomePrimaryTab.community) { - final categoryName = shortcuts - .firstWhere((shortcut) => shortcut.tab == tab) - .categoryName - .toLowerCase(); - if (categoryName.isEmpty) return const []; - source = source.where( - (drama) => - drama.category.toLowerCase().contains(categoryName) || - drama.genre.toLowerCase().contains(categoryName), - ); - } - if (tag != null) { - source = source.where((drama) => matchesHomeTag(drama, tag)); - } - final dramas = source.toList(growable: false); - switch (tab) { - case HomePrimaryTab.newReleases: - return [...dramas]..sort((a, b) { - final left = publishedAtForDrama(a); - final right = publishedAtForDrama(b); - if (left == null && right == null) return b.id.compareTo(a.id); - if (left == null) return 1; - if (right == null) return -1; - return right.compareTo(left); - }); - case HomePrimaryTab.ranking: - return [...dramas]..sort((a, b) => b.viewCount.compareTo(a.viewCount)); - case HomePrimaryTab.featured: - case HomePrimaryTab.categories: - case HomePrimaryTab.costume: - case HomePrimaryTab.community: - return dramas; - } + List _dramasFor(HomeGridCategoryShortcut category) { + final dramas = widget.feedController.dramas; + final categoryName = category.categoryName.toLowerCase(); + if (categoryName.isEmpty) return dramas; + final matched = dramas + .where( + (drama) => + drama.category.toLowerCase().contains(categoryName) || + drama.genre.toLowerCase().contains(categoryName), + ) + .toList(growable: false); + return matched.isEmpty ? dramas : matched; } Widget _emptyState(AppLocalizations l10n) { diff --git a/lib/pages/home/widgets/home_grid_header.dart b/lib/pages/home/widgets/home_grid_header.dart index 131dcc4..9a89ce5 100644 --- a/lib/pages/home/widgets/home_grid_header.dart +++ b/lib/pages/home/widgets/home_grid_header.dart @@ -20,11 +20,13 @@ class HomeGridCategoryShortcut { const HomeGridCategoryShortcut({ required this.label, required this.categoryName, + required this.categoryId, required this.tab, }); final String label; final String categoryName; + final String categoryId; final HomePrimaryTab tab; } @@ -47,15 +49,15 @@ class HomeGridHeader extends StatelessWidget { super.key, required this.callbacks, required this.categories, - required this.selectedPrimary, - required this.onPrimarySelected, + required this.selectedIndex, + required this.onSectionSelected, this.walletBadge, }); final HomeGridCallbacks callbacks; final List categories; - final HomePrimaryTab selectedPrimary; - final ValueChanged onPrimarySelected; + final int selectedIndex; + final ValueChanged onSectionSelected; final int? walletBadge; @override @@ -67,7 +69,7 @@ class HomeGridHeader extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.start, children: [ _searchRow(l10n), - _primaryRow(l10n), + _primaryRow(), const SizedBox(height: AppDimens.homeSecondaryGridGap), ], ); @@ -225,7 +227,73 @@ class HomeGridHeader extends StatelessWidget { ); } - Widget _primaryRow(AppLocalizations l10n) { + Widget _primaryRow() { + return _CategoryBar( + categories: categories, + selectedIndex: selectedIndex, + onSectionSelected: onSectionSelected, + ); + } +} + +class _CategoryBar extends StatefulWidget { + const _CategoryBar({ + required this.categories, + required this.selectedIndex, + required this.onSectionSelected, + }); + + final List categories; + final int selectedIndex; + final ValueChanged onSectionSelected; + + @override + State<_CategoryBar> createState() => _CategoryBarState(); +} + +class _CategoryBarState extends State<_CategoryBar> { + final List _itemKeys = []; + + @override + void initState() { + super.initState(); + _syncKeys(); + WidgetsBinding.instance.addPostFrameCallback((_) => _revealSelected()); + } + + @override + void didUpdateWidget(_CategoryBar oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.categories.length != widget.categories.length) { + _syncKeys(); + } + if (oldWidget.selectedIndex != widget.selectedIndex || + oldWidget.categories.length != widget.categories.length) { + WidgetsBinding.instance.addPostFrameCallback((_) => _revealSelected()); + } + } + + void _syncKeys() { + _itemKeys + ..clear() + ..addAll(List.generate(widget.categories.length, (_) => GlobalKey())); + } + + void _revealSelected() { + final index = widget.selectedIndex; + if (!mounted || index < 0 || index >= _itemKeys.length) return; + final target = _itemKeys[index].currentContext; + if (target == null) return; + Scrollable.ensureVisible( + target, + alignment: 0.5, + duration: AppDimens.homePrimaryTabAnimationDuration, + curve: Curves.easeOut, + ); + } + + @override + Widget build(BuildContext context) { return SingleChildScrollView( key: const Key('home-primary-sections'), scrollDirection: Axis.horizontal, @@ -234,60 +302,29 @@ class HomeGridHeader extends StatelessWidget { ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.start, children: [ - _primaryItem( - key: const Key('home-section-featured'), - label: l10n.homeSectionFeatured, - tab: HomePrimaryTab.featured, - ), - _primaryItem( - key: const Key('home-section-new'), - label: l10n.homeSectionNew, - tab: HomePrimaryTab.newReleases, - ), - _primaryItem( - key: const Key('home-section-categories'), - label: l10n.homeSectionCategories, - tab: HomePrimaryTab.categories, - ), - _primaryItem( - key: const Key('home-section-ranking'), - label: l10n.homeSectionRanking, - tab: HomePrimaryTab.ranking, - ), - for (final category in categories) - _primaryItem( - key: Key( - category.categoryName.isEmpty - ? 'home-section-${category.tab.name}' - : 'home-section-category-${category.categoryName}', - ), - label: category.label, - tab: category.tab, - ), + for (var index = 0; index < widget.categories.length; index++) + _item(index), ], ), ); } - Widget _primaryItem({ - required Key key, - required String label, - required HomePrimaryTab tab, - }) { - final selected = selectedPrimary == tab; + Widget _item(int index) { + final category = widget.categories[index]; + final selected = widget.selectedIndex == index; return InkWell( - key: key, - onTap: () => onPrimarySelected(tab), + key: _itemKeys[index], + onTap: () => widget.onSectionSelected(index), child: Padding( + key: Key('home-section-category-${category.categoryName}'), padding: const EdgeInsets.only(right: AppDimens.homePrimaryCategoryGap), child: SizedBox( height: AppDimens.homePrimaryRowHeight, child: Align( alignment: Alignment.bottomLeft, child: AnimatedDefaultTextStyle( - key: Key('home-primary-label-${tab.name}'), + key: Key('home-primary-label-${category.categoryId}'), duration: AppDimens.homePrimaryTabAnimationDuration, curve: Curves.easeOut, style: TextStyle( @@ -300,7 +337,7 @@ class HomeGridHeader extends StatelessWidget { fontWeight: selected ? FontWeight.w700 : FontWeight.w400, letterSpacing: 0, ), - child: Text(label, textAlign: TextAlign.left), + child: Text(category.label, textAlign: TextAlign.left), ), ), ), diff --git a/lib/pages/mine/widgets/mine_continue_watching.dart b/lib/pages/mine/widgets/mine_continue_watching.dart index 1930979..ac06b13 100644 --- a/lib/pages/mine/widgets/mine_continue_watching.dart +++ b/lib/pages/mine/widgets/mine_continue_watching.dart @@ -82,7 +82,11 @@ class MineContinueWatching extends StatelessWidget { const SizedBox(width: AppDimens.spaceMd), itemBuilder: (_, index) { final record = visible[index]; - return _card(l10n, record); + return _card( + l10n, + record, + Localizations.localeOf(context).languageCode, + ); }, ), ), @@ -90,8 +94,8 @@ class MineContinueWatching extends StatelessWidget { ); } - Widget _card(AppLocalizations l10n, WatchProgress record) { - final title = record.dramaTitle?.trim() ?? ''; + Widget _card(AppLocalizations l10n, WatchProgress record, String languageCode) { + final title = record.localizedTitle(languageCode).trim(); final episode = record.episodeNumber; final hasProgress = record.duration > Duration.zero; return SizedBox( diff --git a/lib/pages/new_releases/new_releases_page.dart b/lib/pages/new_releases/new_releases_page.dart index 86eb30c..6f2aeb3 100644 --- a/lib/pages/new_releases/new_releases_page.dart +++ b/lib/pages/new_releases/new_releases_page.dart @@ -192,6 +192,8 @@ class _NewReleasesPageState extends State { ), PagedLoadFooter( loading: pager.loading, + hasMore: pager.hasMore, + hasItems: visible.isNotEmpty, loadMoreKey: const Key( 'new-releases-load-more', ), diff --git a/lib/pages/notices/notices_page.dart b/lib/pages/notices/notices_page.dart index 5ac2715..f564a03 100644 --- a/lib/pages/notices/notices_page.dart +++ b/lib/pages/notices/notices_page.dart @@ -171,6 +171,17 @@ class _NoticesPageState extends State { label: Text(l10n.retry), ); } + if (_controller.notices.isNotEmpty && !_controller.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(); } diff --git a/lib/pages/player/widgets/comment_thread.dart b/lib/pages/player/widgets/comment_thread.dart new file mode 100644 index 0000000..73149d7 --- /dev/null +++ b/lib/pages/player/widgets/comment_thread.dart @@ -0,0 +1,119 @@ +// Author: fruit.com.cn +// 评论楼中楼:展开回复、继续加载,没有下一页时给出结束提示。 + +import 'package:flutter/material.dart'; + +import '../../../core/data/models/comment.dart'; +import '../../../core/data/services/comment_controller.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../l10n/app_localizations.dart'; +import 'comment_tile.dart'; + +const double _replyIndent = 52; + +/// 一条评论及其回复。 +class CommentThread extends StatelessWidget { + const CommentThread({ + super.key, + required this.comment, + required this.controller, + required this.handlers, + }); + + final DramaComment comment; + final CommentController controller; + final CommentInteractionHandlers handlers; + + @override + Widget build(BuildContext context) { + final replies = controller.repliesFor(comment.id); + final expanded = controller.repliesExpanded(comment.id); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CommentTile(comment: comment, handlers: handlers), + if (comment.replyCount > 0 || replies.isNotEmpty) _toggle(context, expanded), + if (expanded) _replies(context, replies), + ], + ); + } + + Widget _toggle(BuildContext context, bool expanded) { + final l10n = AppLocalizations.of(context)!; + return Padding( + padding: const EdgeInsets.only(left: _replyIndent, bottom: 4), + child: TextButton( + key: Key('comment-replies-${comment.id}'), + onPressed: () => _toggleReplies(context), + style: TextButton.styleFrom( + foregroundColor: AppColors.muted, + padding: EdgeInsets.zero, + minimumSize: const Size(0, 24), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container(width: 16, height: 1, color: AppColors.border), + const SizedBox(width: 8), + Text( + expanded ? l10n.hideReplies : l10n.viewReplies(comment.replyCount), + style: const TextStyle( + color: AppColors.muted, + fontSize: 12, + fontWeight: FontWeight.w400, + ), + ), + const SizedBox(width: 4), + Icon( + expanded + ? Icons.keyboard_arrow_up_rounded + : Icons.keyboard_arrow_down_rounded, + size: 14, + color: AppColors.muted, + ), + ], + ), + ), + ); + } + + Widget _replies(BuildContext context, List replies) { + final l10n = AppLocalizations.of(context)!; + return Padding( + padding: const EdgeInsets.only(left: _replyIndent), + child: Column( + children: [ + if (controller.repliesLoading(comment.id)) + const Padding( + padding: EdgeInsets.symmetric(vertical: 8), + child: CircularProgressIndicator(), + ), + for (final reply in replies) + CommentTile(comment: reply, handlers: handlers, isReply: true), + if (controller.repliesHasMore(comment.id)) + TextButton( + onPressed: () => controller.loadMoreReplies(comment.id), + child: Text(l10n.loadMore), + ) + else if (replies.isNotEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Text( + l10n.noMoreItems, + style: const TextStyle(color: AppColors.textHint, fontSize: 12), + ), + ), + ], + ), + ); + } + + Future _toggleReplies(BuildContext context) async { + if (controller.repliesExpanded(comment.id)) { + controller.hideReplies(comment.id); + return; + } + await controller.loadReplies(comment.id); + } +} diff --git a/lib/pages/player/widgets/drama_comments_sheet.dart b/lib/pages/player/widgets/drama_comments_sheet.dart index 85f3d7e..90d0b89 100644 --- a/lib/pages/player/widgets/drama_comments_sheet.dart +++ b/lib/pages/player/widgets/drama_comments_sheet.dart @@ -19,6 +19,7 @@ import '../../../core/widgets/auth_gate.dart'; import '../../../core/widgets/empty_state.dart'; import '../../../l10n/app_localizations.dart'; import 'comment_composer_sheet.dart'; +import 'comment_thread.dart'; import 'comment_tile.dart'; const double _composerRadius = 22; @@ -26,7 +27,6 @@ const double _composerHeight = 44; const double _sendButtonSize = 38; const double _sheetRadius = 20; const double _headerHeight = 44; -const double _replyIndent = 52; const double _composerTopGap = 8; /// 服务端评论底部弹窗:匿名列表、游标翻页、回复与登录发布。 @@ -315,7 +315,7 @@ class _DramaCommentsSheetState extends State { itemCount: _controller.comments.length + 1, itemBuilder: (context, index) { if (index == _controller.comments.length) return _footer(l10n); - return _CommentThread( + return CommentThread( comment: _controller.comments[index], controller: _controller, handlers: CommentInteractionHandlers(onReply: _beginReply), @@ -338,6 +338,17 @@ class _DramaCommentsSheetState extends State { child: Text(l10n.loadMore), ); } + if (_controller.comments.isNotEmpty) { + return Padding( + key: const Key('paged-list-no-more'), + padding: const EdgeInsets.symmetric(vertical: 12), + child: Text( + l10n.noMoreItems, + textAlign: TextAlign.center, + style: const TextStyle(color: AppColors.textHint, fontSize: 12), + ), + ); + } return const SizedBox(height: 8); } @@ -435,100 +446,3 @@ class _CommentPostSuccessOverlay extends StatelessWidget { ); } } - -class _CommentThread extends StatelessWidget { - const _CommentThread({ - required this.comment, - required this.controller, - required this.handlers, - }); - - final DramaComment comment; - final CommentController controller; - final CommentInteractionHandlers handlers; - - @override - Widget build(BuildContext context) { - final replies = controller.repliesFor(comment.id); - final expanded = controller.repliesExpanded(comment.id); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - CommentTile(comment: comment, handlers: handlers), - if (comment.replyCount > 0 || replies.isNotEmpty) - Padding( - padding: const EdgeInsets.only(left: _replyIndent, bottom: 4), - child: TextButton( - key: Key('comment-replies-${comment.id}'), - onPressed: () => _toggleReplies(context), - style: TextButton.styleFrom( - foregroundColor: AppColors.muted, - padding: EdgeInsets.zero, - minimumSize: const Size(0, 24), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container(width: 16, height: 1, color: AppColors.border), - const SizedBox(width: 8), - Text( - expanded - ? AppLocalizations.of(context)!.hideReplies - : AppLocalizations.of( - context, - )!.viewReplies(comment.replyCount), - style: const TextStyle( - color: AppColors.muted, - fontSize: 12, - fontWeight: FontWeight.w400, - ), - ), - const SizedBox(width: 4), - Icon( - expanded - ? Icons.keyboard_arrow_up_rounded - : Icons.keyboard_arrow_down_rounded, - size: 14, - color: AppColors.muted, - ), - ], - ), - ), - ), - if (expanded) - Padding( - padding: const EdgeInsets.only(left: _replyIndent), - child: Column( - children: [ - if (controller.repliesLoading(comment.id)) - const Padding( - padding: EdgeInsets.symmetric(vertical: 8), - child: CircularProgressIndicator(), - ), - for (final reply in replies) - CommentTile( - comment: reply, - handlers: handlers, - isReply: true, - ), - if (controller.repliesHasMore(comment.id)) - TextButton( - onPressed: () => controller.loadMoreReplies(comment.id), - child: Text(AppLocalizations.of(context)!.loadMore), - ), - ], - ), - ), - ], - ); - } - - Future _toggleReplies(BuildContext context) async { - if (controller.repliesExpanded(comment.id)) { - controller.hideReplies(comment.id); - return; - } - await controller.loadReplies(comment.id); - } -} diff --git a/lib/pages/player/widgets/home_feed_recommend_panel.dart b/lib/pages/player/widgets/home_feed_recommend_panel.dart index b8c1b3a..22a7af0 100644 --- a/lib/pages/player/widgets/home_feed_recommend_panel.dart +++ b/lib/pages/player/widgets/home_feed_recommend_panel.dart @@ -49,7 +49,7 @@ class _HomeFeedInfoPanelState extends State { final l10n = AppLocalizations.of(context)!; final dramaTitle = localizedDramaTitle(l10n, widget.drama); final synopsis = localizedDramaSynopsis(l10n, widget.drama).trim(); - final genres = _genreValues(); + final genres = _genreValues(Localizations.localeOf(context).languageCode); return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, @@ -285,7 +285,9 @@ class _HomeFeedInfoPanelState extends State { ); } - List _genreValues() { + List _genreValues(String languageCode) { + final localized = widget.drama.localizedTypeLabels(languageCode); + if (localized.isNotEmpty) return localized.take(3).toList(growable: false); final result = []; for (final value in [widget.drama.genre, widget.drama.category]) { for (final part in value.split(_tagSplitter)) { diff --git a/lib/pages/ranking/ranking_page.dart b/lib/pages/ranking/ranking_page.dart index 105cd02..07f745e 100644 --- a/lib/pages/ranking/ranking_page.dart +++ b/lib/pages/ranking/ranking_page.dart @@ -153,6 +153,8 @@ class _RankingPageState extends State { ], PagedLoadFooter( loading: _pager!.loading, + hasMore: _pager!.hasMore, + hasItems: _pager!.visibleItems.isNotEmpty, loadMoreKey: const Key('ranking-load-more'), ), ], diff --git a/lib/pages/recommend/recommend_page.dart b/lib/pages/recommend/recommend_page.dart index bf46206..4f29b3a 100644 --- a/lib/pages/recommend/recommend_page.dart +++ b/lib/pages/recommend/recommend_page.dart @@ -6,8 +6,11 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../core/data/models/drama.dart'; +import '../../core/data/remote/catalog_api.dart'; +import '../../core/data/models/playlist_detail.dart'; import '../../core/data/services/category_controller.dart'; import '../../core/data/services/home_feed_controller.dart'; +import '../../core/data/services/playlist_controller.dart'; import '../../core/theme/app_colors.dart'; import '../../core/widgets/empty_state.dart'; import '../../l10n/app_localizations.dart'; @@ -23,6 +26,7 @@ class RecommendPage extends StatefulWidget { const RecommendPage({ super.key, required this.feedController, + this.catalogApi, required this.active, required this.onSearch, this.videoOnly = false, @@ -30,6 +34,7 @@ class RecommendPage extends StatefulWidget { }); final HomeFeedController feedController; + final CatalogApi? catalogApi; final bool active; final VoidCallback onSearch; @@ -46,6 +51,32 @@ class _RecommendPageState extends State { final PageController _pageController = PageController(); int _scrubCancelRequest = 0; + @override + void initState() { + super.initState(); + if (widget.active) _scheduleRecommendData(); + } + + @override + void didUpdateWidget(RecommendPage oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.active && !oldWidget.active) _scheduleRecommendData(); + } + + void _scheduleRecommendData() { + final feed = widget.feedController; + if (!feed.loaded && !feed.loadingFirstPage) { + unawaited(feed.refreshCurrent()); + } + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !widget.active) return; + final playlists = _readPlaylists(); + if (playlists != null && !playlists.loaded && !playlists.loading) { + unawaited(playlists.load()); + } + }); + } + @override void dispose() { _pageController.dispose(); @@ -82,6 +113,7 @@ class _RecommendPageState extends State { child: _withTopNavSpace( AllDramasFilterGrid( feedController: widget.feedController, + catalogApi: widget.catalogApi, onOpenDrama: _openDrama, ), ), @@ -168,6 +200,22 @@ class _RecommendPageState extends State { ); } + PlaylistController? _watchPlaylists(BuildContext context) { + try { + return context.watch(); + } on ProviderNotFoundException { + return null; + } + } + + PlaylistController? _readPlaylists() { + try { + return context.read(); + } on ProviderNotFoundException { + return null; + } + } + Widget _recommendPlaylists(CategoryController categories) { return DecoratedBox( key: const Key('recommend-playlist-surface'), @@ -205,6 +253,8 @@ class _RecommendPageState extends State { categories: categories, onOpenDrama: _openDrama, onMore: _handleMore, + playlists: _watchPlaylists(context), + onOpenPlaylist: _openPlaylist, ), ), ], @@ -252,6 +302,36 @@ class _RecommendPageState extends State { ); } + Future _openPlaylist(PlaylistDetail playlist) async { + final controller = _readPlaylists(); + const pageSize = CatalogApi.playlistDramaPageSize; + final fresh = controller == null + ? playlist + : await controller.detail(playlist.id, pageSize: pageSize); + if (!mounted) return; + final shown = fresh.dramas.isEmpty && playlist.dramas.isNotEmpty + ? playlist + : fresh; + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => PlaylistDetailPage( + title: shown.name, + subtitle: shown.description, + source: shown, + dramas: shown.dramas, + dramaCount: shown.dramaCount, + loadPage: controller == null + ? null + : (pageIndex) => controller.detail( + playlist.id, + pageIndex: pageIndex, + pageSize: pageSize, + ), + ), + ), + ); + } + void _handleMore(RecommendSectionKind section) { final l10n = AppLocalizations.of(context)!; final copy = _sectionCopy(l10n, section); diff --git a/lib/pages/recommend/widgets/all_dramas_filter_grid.dart b/lib/pages/recommend/widgets/all_dramas_filter_grid.dart index f7881da..7f43e8b 100644 --- a/lib/pages/recommend/widgets/all_dramas_filter_grid.dart +++ b/lib/pages/recommend/widgets/all_dramas_filter_grid.dart @@ -1,10 +1,14 @@ // Author: fruit.com.cn +// 全部短剧:分类、类型、标签多选,列表下拉刷新、上拉加载。 import 'dart:async'; import 'package:flutter/material.dart'; +import '../../../core/data/models/content_category.dart'; import '../../../core/data/models/drama.dart'; +import '../../../core/data/remote/catalog_api.dart'; +import '../../../core/data/services/all_dramas_controller.dart'; import '../../../core/data/services/home_feed_controller.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; @@ -12,21 +16,24 @@ import '../../../core/widgets/app_pull_to_refresh.dart'; import '../../../core/widgets/empty_state.dart'; import '../../../core/widgets/paged_list_footer.dart'; import '../../../l10n/app_localizations.dart'; -import '../../home/widgets/home_drama_classification.dart'; import '../../home/widgets/home_feed_card.dart'; const double _loadMoreThreshold = 240; -const List _modernTerms = ['现代', 'modern', 'moderno']; -const List _reversalTerms = ['逆袭', 'comeback', 'balas']; +const int _gridColumns = 3; +/// 推荐页「全部短剧」筛选和三列列表。 class AllDramasFilterGrid extends StatefulWidget { const AllDramasFilterGrid({ super.key, - required this.feedController, required this.onOpenDrama, + this.feedController, + this.catalogApi, + this.controller, }); - final HomeFeedController feedController; + final HomeFeedController? feedController; + final CatalogApi? catalogApi; + final AllDramasController? controller; final ValueChanged onOpenDrama; @override @@ -34,102 +41,143 @@ class AllDramasFilterGrid extends StatefulWidget { } class _AllDramasFilterGridState extends State { - String _region = 'all'; - String _audio = 'all'; - String? _genre; - bool _newest = false; + late final AllDramasController _library; + + @override + void initState() { + super.initState(); + _library = widget.controller ?? + AllDramasController( + api: widget.catalogApi == null + ? null + : CatalogAllDramasRemote(widget.catalogApi!), + ); + if (_library.usesRemote) { + unawaited(_reload()); + } + } + + Future _reload() async { + await _library.refresh(); + if (mounted) setState(() {}); + } + + Future _loadMore() async { + if (_library.usesRemote) { + await _library.loadMore(); + if (mounted) setState(() {}); + return; + } + await widget.feedController?.loadMore(); + } + + List get _dramas => + _library.usesRemote ? _library.dramas : widget.feedController?.dramas ?? _library.dramas; @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; - final dramas = _filtered(); return LayoutBuilder( builder: (context, constraints) { - final itemWidth = - (constraints.maxWidth - - AppDimens.allDramasHorizontal * 2 - - AppDimens.allDramasGridGap * 2) / - 3; - final itemExtent = - itemWidth / AppDimens.cardCoverAspectRatio + - AppDimens.homeCardBodyExtent; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _filters(l10n), - Expanded( - child: NotificationListener( - onNotification: _onScroll, - child: AppPullToRefresh( - key: const Key('all-dramas-refresh'), - onRefresh: () async { - await widget.feedController.refreshCurrent(); - }, - child: CustomScrollView( - key: const Key('all-dramas-scroll'), - physics: AppPullToRefresh.physics, - slivers: [ - if (dramas.isEmpty) - SliverFillRemaining( - hasScrollBody: false, - child: EmptyState( - key: const Key('all-dramas-empty'), - icon: Icons.movie_filter_outlined, - title: l10n.emptyContent, - ), - ) - else - SliverPadding( - padding: const EdgeInsets.symmetric( - horizontal: AppDimens.allDramasHorizontal, - ), - sliver: SliverGrid( - key: const Key('all-dramas-grid'), - gridDelegate: - SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - crossAxisSpacing: AppDimens.allDramasGridGap, - mainAxisSpacing: - AppDimens.allDramasGridRowGap, - mainAxisExtent: itemExtent, - ), - delegate: SliverChildBuilderDelegate( - (context, index) => HomeFeedCard( - drama: dramas[index], - onTap: () => widget.onOpenDrama(dramas[index]), - ), - childCount: dramas.length, - ), - ), - ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.only( - bottom: AppDimens.spaceXl, - ), - child: PagedListFooter( - loading: widget.feedController.loadingMore, - errorMessage: - widget.feedController.lastError == null - ? null - : l10n.catalogError, - onRetry: widget.feedController.loadMore, - ), - ), - ), - ], - ), - ), - ), - ), + Expanded(child: _dramaList(l10n, _itemExtent(constraints.maxWidth))), ], ); }, ); } + double _itemExtent(double width) { + final itemWidth = + (width - AppDimens.allDramasHorizontal * 2 - AppDimens.allDramasGridGap * 2) / + _gridColumns; + return itemWidth / AppDimens.cardCoverAspectRatio + AppDimens.homeCardBodyExtent; + } + + Widget _dramaList(AppLocalizations l10n, double itemExtent) { + final dramas = _dramas; + return NotificationListener( + onNotification: _onScroll, + child: AppPullToRefresh( + key: const Key('all-dramas-refresh'), + onRefresh: _refresh, + child: CustomScrollView( + key: const Key('all-dramas-scroll'), + physics: AppPullToRefresh.physics, + slivers: [ + if (dramas.isEmpty) _empty(l10n) else _grid(dramas, itemExtent), + _footer(l10n, dramas.isNotEmpty), + ], + ), + ), + ); + } + + Future _refresh() async { + if (_library.usesRemote) { + await _reload(); + return; + } + await widget.feedController?.refreshCurrent(); + } + + Widget _empty(AppLocalizations l10n) { + return SliverFillRemaining( + hasScrollBody: false, + child: EmptyState( + key: const Key('all-dramas-empty'), + icon: Icons.movie_filter_outlined, + title: l10n.emptyContent, + ), + ); + } + + Widget _grid(List dramas, double itemExtent) { + return SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: AppDimens.allDramasHorizontal), + sliver: SliverGrid( + key: const Key('all-dramas-grid'), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: _gridColumns, + crossAxisSpacing: AppDimens.allDramasGridGap, + mainAxisSpacing: AppDimens.allDramasGridRowGap, + mainAxisExtent: itemExtent, + ), + delegate: SliverChildBuilderDelegate( + (context, index) => HomeFeedCard( + drama: dramas[index], + onTap: () => widget.onOpenDrama(dramas[index]), + ), + childCount: dramas.length, + ), + ), + ); + } + + Widget _footer(AppLocalizations l10n, bool hasItems) { + return SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.only(bottom: AppDimens.spaceXl), + child: PagedListFooter( + loading: _library.usesRemote + ? _library.loadingMore + : widget.feedController?.loadingMore ?? false, + hasMore: _library.usesRemote + ? _library.hasMore + : widget.feedController?.hasMore ?? false, + hasItems: hasItems, + errorMessage: _library.error == null ? null : l10n.catalogError, + onRetry: _loadMore, + ), + ), + ); + } + Widget _filters(AppLocalizations l10n) { - final genres = _availableGenres(l10n); + final languageCode = Localizations.localeOf(context).languageCode; return Padding( padding: const EdgeInsets.fromLTRB( AppDimens.allDramasHorizontal, @@ -140,60 +188,75 @@ class _AllDramasFilterGridState extends State { child: Column( key: const Key('all-dramas-filters'), children: [ - _selectableRow( - 0, - [ - (id: 'all', label: l10n.all), - (id: 'local', label: l10n.localFilter), - (id: 'asia', label: l10n.asiaFilter), - (id: 'western', label: l10n.westernFilter), - ], - selected: _region, - onSelected: (value) => setState(() => _region = value), + _optionRow( + _FilterRow( + row: 0, + group: 'classification', + options: _library.classifications, + selected: _library.selectedClassifications, + onToggle: _library.toggleClassification, + ), + languageCode, + l10n, ), - _selectableRow( - 1, - [ - (id: 'all', label: l10n.all), - (id: 'subtitles', label: l10n.subtitlesOnlyFilter), - (id: 'dubbed', label: l10n.dubbedFilter), - ], - selected: _audio, - onSelected: (value) => setState(() => _audio = value), + _optionRow( + _FilterRow( + row: 1, + group: 'category', + options: _library.categories, + selected: _library.selectedCategories, + onToggle: _library.toggleCategory, + ), + languageCode, + l10n, + ), + _optionRow( + _FilterRow( + row: 2, + group: 'tag', + options: _library.tags, + selected: _library.selectedTags, + onToggle: _library.toggleTag, + ), + languageCode, + l10n, ), - _genreRow(2, genres, l10n), - _sortRow(l10n), ], ), ); } - Widget _selectableRow( - int row, - List<({String id, String label})> options, { - required String selected, - required ValueChanged onSelected, - }) { + Widget _optionRow(_FilterRow spec, String languageCode, AppLocalizations l10n) { return _filterRow( - row: row, + row: spec.row, child: _horizontalChipScroll( - scrollKey: Key('all-filter-scroll-$row'), + scrollKey: Key('all-filter-scroll-${spec.row}'), chips: [ - for (var index = 0; index < options.length; index++) ...[ + _chip( + key: Key('all-filter-${spec.group}-all'), + label: l10n.all, + selected: spec.selected.isEmpty, + onTap: () => _select(spec.onToggle, ''), + ), + for (final option in spec.options) ...[ + const SizedBox(width: AppDimens.filterItemGap), _chip( - key: Key('all-filter-${options[index].id}'), - label: options[index].label, - selected: selected == options[index].id, - onTap: () => onSelected(options[index].id), + key: Key('all-filter-${spec.group}-${option.id}'), + label: option.localizedName(languageCode), + selected: spec.selected.contains(option.id), + onTap: () => _select(spec.onToggle, option.id), ), - if (index < options.length - 1) - const SizedBox(width: AppDimens.filterItemGap), ], ], ), ); } + void _select(void Function(String id) onToggle, String id) { + setState(() => onToggle(id)); + if (_library.usesRemote) unawaited(_reload()); + } + Widget _horizontalChipScroll({ required Key scrollKey, required List chips, @@ -205,43 +268,17 @@ class _AllDramasFilterGridState extends State { physics: const BouncingScrollPhysics(), child: ConstrainedBox( constraints: BoxConstraints(minWidth: constraints.maxWidth), - child: Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.center, - children: chips, - ), + child: Row(children: chips), ), ), ); } - Widget _filterRow({ - required int row, - required Widget child, - double height = AppDimens.filterChipHeight, - }) { + Widget _filterRow({required int row, required Widget child}) { return Padding( key: Key('all-filter-row-$row'), - padding: EdgeInsets.only(bottom: row < 3 ? AppDimens.filterRowGap : 0), - child: SizedBox(height: height, child: child), - ); - } - - Widget _genreRow( - int row, - List<({String id, String label})> options, - AppLocalizations l10n, - ) { - return _selectableRow( - row, - [ - (id: 'all', label: l10n.all), - for (final genre in options) (id: genre.id, label: genre.label), - ], - selected: _genre ?? 'all', - onSelected: (value) => - setState(() => _genre = value == 'all' ? null : value), + padding: EdgeInsets.only(bottom: row < 2 ? AppDimens.filterRowGap : 0), + child: SizedBox(height: AppDimens.filterChipHeight, child: child), ); } @@ -262,9 +299,7 @@ class _AllDramasFilterGridState extends State { ), alignment: Alignment.center, decoration: BoxDecoration( - color: selected - ? AppColors.filterChipSelected - : AppColors.transparent, + color: selected ? AppColors.filterChipSelected : AppColors.transparent, borderRadius: BorderRadius.circular(AppDimens.filterChipRadius), ), child: Text( @@ -282,84 +317,32 @@ class _AllDramasFilterGridState extends State { ); } - Widget _sortRow(AppLocalizations l10n) { - return _filterRow( - row: 3, - child: _horizontalChipScroll( - scrollKey: const Key('all-filter-scroll-3'), - chips: [ - _chip( - key: const Key('all-sort-hot'), - label: l10n.hot, - selected: !_newest, - onTap: () => setState(() => _newest = false), - ), - const SizedBox(width: AppDimens.filterItemGap), - _chip( - key: const Key('all-sort-new'), - label: l10n.newest, - selected: _newest, - onTap: () => setState(() => _newest = true), - ), - ], - ), - ); - } - - List<({String id, String label})> _availableGenres(AppLocalizations l10n) { - final definitions = [ - (id: 'reversal', label: l10n.reversalFilter, terms: _reversalTerms), - (id: 'uplifting', label: l10n.homeTagUplifting, terms: []), - (id: 'modern', label: l10n.modernFilter, terms: _modernTerms), - (id: 'sweet', label: l10n.homeTagSweet, terms: []), - (id: 'angst', label: l10n.homeTagAngst, terms: []), - ]; - return definitions - .map((definition) => (id: definition.id, label: definition.label)) - .toList(growable: false); - } - - List _filtered() { - if (_region != 'all' || _audio != 'all') return const []; - final dramas = widget.feedController.dramas - .where((drama) { - return _genre == null || _matchesGenre(drama, _genre!, const []); - }) - .toList(growable: false); - if (_newest) { - return [...dramas]..sort((a, b) { - final left = publishedAtForDrama(a); - final right = publishedAtForDrama(b); - if (left == null && right == null) return b.id.compareTo(a.id); - if (left == null) return 1; - if (right == null) return -1; - return right.compareTo(left); - }); - } - return [...dramas]..sort((a, b) => b.viewCount.compareTo(a.viewCount)); - } - - bool _matchesGenre(Drama drama, String id, List terms) { - if (id == 'reversal' || - id == 'uplifting' || - id == 'sweet' || - id == 'angst') { - return matchesHomeTag(drama, id == 'reversal' ? 'uplifting' : id); - } - final text = '${drama.genre} ${drama.category}'.toLowerCase(); - return terms.any(text.contains); - } - bool _onScroll(ScrollNotification notification) { if (notification.depth != 0) return false; - final reachedBottom = - notification.metrics.extentAfter <= _loadMoreThreshold; + if (notification.metrics.pixels <= 0) return false; + final reachedBottom = notification.metrics.extentAfter <= _loadMoreThreshold; if (reachedBottom && (notification is ScrollUpdateNotification || notification is ScrollEndNotification || notification is OverscrollNotification)) { - unawaited(widget.feedController.loadMore()); + unawaited(_loadMore()); } return false; } } + +class _FilterRow { + const _FilterRow({ + required this.row, + required this.group, + required this.options, + required this.selected, + required this.onToggle, + }); + + final int row; + final String group; + final List options; + final Set selected; + final void Function(String id) onToggle; +} diff --git a/lib/pages/recommend/widgets/playlist_detail_page.dart b/lib/pages/recommend/widgets/playlist_detail_page.dart index 3eb0dbc..d3e04b4 100644 --- a/lib/pages/recommend/widgets/playlist_detail_page.dart +++ b/lib/pages/recommend/widgets/playlist_detail_page.dart @@ -3,15 +3,20 @@ import 'package:flutter/material.dart'; import '../../../core/data/models/drama.dart'; +import '../../../core/data/models/playlist_detail.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; import '../../../core/theme/app_theme.dart'; +import '../../../core/widgets/app_pull_to_refresh.dart'; +import '../../../core/widgets/paged_list_footer.dart'; import '../../../core/widgets/remote_image.dart'; +import '../../../l10n/app_localizations.dart'; import '../../home/widgets/home_feed_card.dart'; import '../../player/open_player.dart'; const double _collageHeight = 188; const int _collageCoverCount = 5; +const double _loadMoreThreshold = 200; const double _pageHorizontal = 14; const double _gridGap = 10; const double _sprocketBand = 22; @@ -19,17 +24,88 @@ const Color _filmBody = Color(0xFF161311); const Color _filmHole = Color(0xFFE6D3BE); /// 剧单「更多」:顶部拼图、与剧单一致的标题描述,以及两列卡片。 -class PlaylistDetailPage extends StatelessWidget { +class PlaylistDetailPage extends StatefulWidget { const PlaylistDetailPage({ super.key, required this.title, required this.subtitle, required this.dramas, + this.dramaCount = 0, + this.source, + this.loadPage, }); final String title; final String subtitle; final List dramas; + final PlaylistDetail? source; + final int dramaCount; + final Future Function(int pageIndex)? loadPage; + + @override + State createState() => _PlaylistDetailPageState(); +} + +class _PlaylistDetailPageState extends State { + late List _dramas = widget.dramas; + late int _dramaCount = widget.dramaCount; + int _pageIndex = 1; + bool _loadingMore = false; + bool _failed = false; + + bool get _hasMore => + widget.loadPage != null && _dramas.length < _dramaCount; + + Future _refresh() async { + final loadPage = widget.loadPage; + if (loadPage == null) return; + try { + final page = await loadPage(1); + if (!mounted) return; + setState(() { + _dramas = page.dramas; + _dramaCount = page.dramaCount; + _pageIndex = page.pageIndex == 0 ? 1 : page.pageIndex; + _failed = false; + }); + } catch (_) { + if (!mounted) return; + setState(() => _failed = true); + } + } + + Future _loadMore() async { + final loadPage = widget.loadPage; + if (loadPage == null || _loadingMore || !_hasMore) return; + setState(() => _loadingMore = true); + final next = _pageIndex + 1; + try { + final page = await loadPage(next); + if (!mounted) return; + setState(() { + _dramas = [..._dramas, ...page.dramas]; + _dramaCount = page.dramaCount; + _pageIndex = page.pageIndex == 0 ? next : page.pageIndex; + _failed = false; + }); + } catch (_) { + if (!mounted) return; + setState(() => _failed = true); + } finally { + if (mounted) setState(() => _loadingMore = false); + } + } + + bool _onScroll(ScrollNotification notification) { + if (!_hasMore || notification.depth != 0) return false; + if (notification.metrics.extentAfter > _loadMoreThreshold) return false; + if (notification is! ScrollUpdateNotification && + notification is! ScrollEndNotification) { + return false; + } + _loadMore(); + return false; + } @override Widget build(BuildContext context) { @@ -52,7 +128,16 @@ class PlaylistDetailPage extends StatelessWidget { icon: const Icon(Icons.chevron_left_rounded), color: AppColors.textPrimary, ), - Expanded(child: _body(itemExtent)), + Expanded( + child: NotificationListener( + onNotification: _onScroll, + child: AppPullToRefresh( + key: const Key('playlist-detail-refresh'), + onRefresh: _refresh, + child: _body(itemExtent), + ), + ), + ), ], ), ), @@ -63,6 +148,7 @@ class PlaylistDetailPage extends StatelessWidget { Widget _body(double itemExtent) { return CustomScrollView( key: const Key('playlist-detail-scroll'), + physics: AppPullToRefresh.physics, slivers: [ SliverPadding( padding: const EdgeInsets.fromLTRB( @@ -71,7 +157,7 @@ class PlaylistDetailPage extends StatelessWidget { _pageHorizontal, 12, ), - sliver: SliverToBoxAdapter(child: _PlaylistCollage(dramas: dramas)), + sliver: SliverToBoxAdapter(child: _PlaylistCollage(dramas: _dramas)), ), SliverPadding( padding: const EdgeInsets.symmetric(horizontal: _pageHorizontal), @@ -95,10 +181,22 @@ class PlaylistDetailPage extends StatelessWidget { ), delegate: SliverChildBuilderDelegate( (context, index) => HomeFeedCard( - drama: dramas[index], - onTap: () => playDramaFromCatalog(context, dramas[index]), + drama: _dramas[index], + onTap: () => playDramaFromCatalog(context, _dramas[index]), ), - childCount: dramas.length, + childCount: _dramas.length, + ), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.only(bottom: 24), + child: PagedListFooter( + loading: _loadingMore, + hasMore: _hasMore, + hasItems: _dramas.isNotEmpty, + errorMessage: _failed ? AppLocalizations.of(context)!.catalogError : null, + onRetry: _loadingMore ? _loadMore : (_hasMore ? _loadMore : _refresh), ), ), ), @@ -106,12 +204,26 @@ class PlaylistDetailPage extends StatelessWidget { ); } + String _title(BuildContext context) { + final item = widget.source; + if (item == null) return widget.title; + return item.localizedName(Localizations.localeOf(context).languageCode); + } + + String _subtitle(BuildContext context) { + final item = widget.source; + if (item == null) return widget.subtitle; + return item.localizedDescription( + Localizations.localeOf(context).languageCode, + ); + } + Widget _heading() { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - title, + _title(context), key: const Key('playlist-detail-title'), style: const TextStyle( color: AppColors.textPrimary, @@ -121,7 +233,7 @@ class PlaylistDetailPage extends StatelessWidget { ), const SizedBox(height: 2), Text( - subtitle, + _subtitle(context), key: const Key('playlist-detail-subtitle'), style: const TextStyle( color: AppColors.textSecondary, diff --git a/lib/pages/recommend/widgets/recommend_section_card.dart b/lib/pages/recommend/widgets/recommend_section_card.dart index 8a0d477..eaf53c1 100644 --- a/lib/pages/recommend/widgets/recommend_section_card.dart +++ b/lib/pages/recommend/widgets/recommend_section_card.dart @@ -8,8 +8,6 @@ import '../../../core/theme/app_dimens.dart'; import '../../../core/widgets/cover_play_count.dart'; import '../../../core/widgets/drama_cover_badge_ribbon.dart'; import '../../../core/widgets/remote_image.dart'; -import '../../../l10n/app_localizations.dart'; -import '../../home/widgets/home_drama_classification.dart'; final RegExp _tagSplitter = RegExp(r'[,/、·]'); @@ -27,7 +25,7 @@ class RecommendSectionCard extends StatelessWidget { @override Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context)!; + final tagLabel = drama.localizedTag(Localizations.localeOf(context).languageCode); return SizedBox( width: AppDimens.recommendCardWidth, child: InkWell( @@ -52,14 +50,14 @@ class RecommendSectionCard extends StatelessWidget { showFallbackIcon: true, fallbackColor: AppColors.contentSurface, ), - if (isHotDrama(drama)) + if (tagLabel.isNotEmpty) Positioned( top: 0, right: 0, child: DramaCoverBadgeRibbon( - label: l10n.homeBadgeHot, - color: AppColors.badgeHot, - keyPrefix: '$sectionKey-${drama.id}-hot', + label: tagLabel, + color: drama.tagRibbonColor(AppColors.badgeHot), + keyPrefix: '$sectionKey-${drama.id}-tag', ), ), const Positioned( @@ -82,7 +80,7 @@ class RecommendSectionCard extends StatelessWidget { ), const SizedBox(height: AppDimens.cardCoverTitleGap), Text( - drama.title, + drama.localizedTitle(Localizations.localeOf(context).languageCode), key: Key('recommend-title-$sectionKey-${drama.id}'), maxLines: 2, overflow: TextOverflow.ellipsis, @@ -94,25 +92,15 @@ class RecommendSectionCard extends StatelessWidget { ), ), const SizedBox(height: AppDimens.cardTitleTagGap), - _tags(), + _tags(context), ], ), ), ); } - List _cardTags() { - final seen = {}; - return [drama.genre, drama.category] - .expand((value) => value.split(_tagSplitter)) - .map((value) => value.trim()) - .where((value) => value.isNotEmpty && seen.add(value)) - .take(3) - .toList(growable: false); - } - - Widget _tags() { - final tags = _cardTags(); + Widget _tags(BuildContext context) { + final tags = _cardTags(context); if (tags.isEmpty) return const SizedBox.shrink(); return Text( tags.join(' | '), @@ -126,4 +114,16 @@ class RecommendSectionCard extends StatelessWidget { ), ); } + + List _cardTags(BuildContext context) { + final localized = drama.localizedTypeLabels(Localizations.localeOf(context).languageCode); + if (localized.isNotEmpty) return localized.take(3).toList(growable: false); + final seen = {}; + return [drama.genre, drama.category] + .expand((value) => value.split(_tagSplitter)) + .map((value) => value.trim()) + .where((value) => value.isNotEmpty && seen.add(value)) + .take(3) + .toList(growable: false); + } } diff --git a/lib/pages/recommend/widgets/recommend_sections_view.dart b/lib/pages/recommend/widgets/recommend_sections_view.dart index 1133ce7..69418a9 100644 --- a/lib/pages/recommend/widgets/recommend_sections_view.dart +++ b/lib/pages/recommend/widgets/recommend_sections_view.dart @@ -4,17 +4,21 @@ import 'package:flutter/material.dart'; import '../../../core/data/models/content_category.dart'; import '../../../core/data/models/drama.dart'; +import '../../../core/data/models/playlist_detail.dart'; import '../../../core/data/services/category_controller.dart'; import '../../../core/data/services/home_feed_controller.dart'; +import '../../../core/data/services/playlist_controller.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; import '../../../core/widgets/app_pull_to_refresh.dart'; +import '../../../core/widgets/empty_state.dart'; import '../../../l10n/app_localizations.dart'; import '../../home/widgets/home_drama_classification.dart'; import 'recommend_section_card.dart'; const List _costumeNames = ['古装', 'costume', 'kostum']; const int _sectionPreviewCount = 10; +const double _loadMoreThreshold = 200; enum RecommendSectionKind { hits, newReleases, costume } @@ -25,17 +29,51 @@ class RecommendSectionsView extends StatelessWidget { required this.categories, required this.onOpenDrama, required this.onMore, + this.playlists, + this.onOpenPlaylist, }); final HomeFeedController feedController; final CategoryController categories; final ValueChanged onOpenDrama; final ValueChanged onMore; + final PlaylistController? playlists; + final ValueChanged? onOpenPlaylist; @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; final source = feedController.dramas; + final remote = playlists; + return AppPullToRefresh( + key: const Key('recommend-sections-refresh'), + onRefresh: () async { + await Future.wait([ + feedController.refreshCurrent(), + categories.load(), + if (remote != null) remote.load(), + ]); + }, + child: NotificationListener( + onNotification: (notification) => + remote == null ? false : _onPlaylistScroll(notification, remote), + child: ListView( + key: const Key('recommend-sections-scroll'), + physics: AppPullToRefresh.physics, + padding: const EdgeInsets.only(bottom: AppDimens.spaceXl), + children: remote == null + ? _localSections(context, l10n, source) + : _remoteSections(context, l10n, remote), + ), + ), + ); + } + + List _localSections( + BuildContext context, + AppLocalizations l10n, + List source, + ) { final hits = recommendSectionDramas( kind: RecommendSectionKind.hits, source: source, @@ -44,16 +82,7 @@ class RecommendSectionsView extends StatelessWidget { kind: RecommendSectionKind.newReleases, source: source, ); - return AppPullToRefresh( - key: const Key('recommend-sections-refresh'), - onRefresh: () async { - await Future.wait([feedController.refreshCurrent(), categories.load()]); - }, - child: ListView( - key: const Key('recommend-sections-scroll'), - physics: AppPullToRefresh.physics, - padding: const EdgeInsets.only(bottom: AppDimens.spaceXl), - children: [ + return [ _section( context, l10n, @@ -87,9 +116,97 @@ class RecommendSectionsView extends StatelessWidget { ).take(_sectionPreviewCount).toList(), onMore: () => onMore(RecommendSectionKind.costume), ), - ], - ), - ); + ]; + } + + List _remoteSections( + BuildContext context, + AppLocalizations l10n, + PlaylistController remote, + ) { + if (!remote.loaded && remote.loading) { + return const [ + SizedBox( + height: 240, + child: Center( + child: CircularProgressIndicator( + key: Key('recommend-playlists-loading'), + ), + ), + ), + ]; + } + if (remote.items.isEmpty) { + return [ + EmptyState( + key: const Key('recommend-playlists-empty'), + icon: Icons.video_library_outlined, + title: remote.error == null ? l10n.emptyContent : l10n.networkError, + actionLabel: l10n.retry, + onAction: remote.load, + ), + ]; + } + final sections = []; + for (final playlist in remote.items) { + if (sections.isNotEmpty) { + sections.add(const SizedBox(height: AppDimens.spaceXl)); + } + sections.add( + _section( + context, + l10n, + key: playlist.id, + title: playlist.localizedName( + Localizations.localeOf(context).languageCode, + ), + subtitle: playlist.localizedDescription( + Localizations.localeOf(context).languageCode, + ), + dramas: playlist.dramas.take(_sectionPreviewCount).toList(), + onMore: () => onOpenPlaylist?.call(playlist), + ), + ); + } + if (remote.loadingMore) { + sections.add( + const Padding( + padding: EdgeInsets.symmetric(vertical: AppDimens.spaceMd), + child: Center( + child: CircularProgressIndicator( + key: Key('recommend-playlists-more'), + ), + ), + ), + ); + } else if (!remote.hasMore) { + sections.add( + 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 sections; + } + + bool _onPlaylistScroll( + ScrollNotification notification, + PlaylistController remote, + ) { + if (notification.depth != 0) return false; + if (notification.metrics.extentAfter > _loadMoreThreshold) return false; + if (notification is! ScrollUpdateNotification && + notification is! ScrollEndNotification) { + return false; + } + remote.loadMore(); + return false; } Widget _section( diff --git a/lib/pages/watch_records/watch_records_page.dart b/lib/pages/watch_records/watch_records_page.dart index b996c22..b244c63 100644 --- a/lib/pages/watch_records/watch_records_page.dart +++ b/lib/pages/watch_records/watch_records_page.dart @@ -145,6 +145,8 @@ class _WatchRecordsBodyState extends State<_WatchRecordsBody> { ], PagedListFooter( loading: controller.loadingMore, + hasMore: controller.hasMore, + hasItems: controller.records.isNotEmpty, errorMessage: controller.loadMoreError == null ? null : errorText(l10n, controller.loadMoreError, l10n.progressError), diff --git a/lib/pages/watch_records/widgets/watch_record_card.dart b/lib/pages/watch_records/widgets/watch_record_card.dart index 4fa1f0b..b889936 100644 --- a/lib/pages/watch_records/widgets/watch_record_card.dart +++ b/lib/pages/watch_records/widgets/watch_record_card.dart @@ -52,9 +52,10 @@ class WatchRecordCard extends StatelessWidget { final episodeLabel = episodeNumber > 0 ? l10n.episodeLabel(record.episodeNumber) : l10n.episodeUnavailable; - final title = record.displayTitle.isEmpty - ? episodeLabel - : record.displayTitle; + final titleText = record.localizedDisplayTitle( + Localizations.localeOf(context).languageCode, + ); + final title = titleText.isEmpty ? episodeLabel : titleText; return _card( onTap: onOpen, child: Stack( diff --git a/test/config/server_endpoint_test.dart b/test/config/server_endpoint_test.dart index 4bcce55..b0ad05b 100644 --- a/test/config/server_endpoint_test.dart +++ b/test/config/server_endpoint_test.dart @@ -63,7 +63,7 @@ void main() { }); test('should_use_the_configured_local_address_for_device_default', () { - expect(AppConfig.devHostBaseUrl, 'http://10.43.47.1:8081'); + expect(AppConfig.devHostBaseUrl, 'http://192.168.31.243:8081'); }); test('should_skip_probe_when_compiled_override_is_set', () async { diff --git a/test/core/data/services/all_dramas_controller_test.dart b/test/core/data/services/all_dramas_controller_test.dart new file mode 100644 index 0000000..739b828 --- /dev/null +++ b/test/core/data/services/all_dramas_controller_test.dart @@ -0,0 +1,128 @@ +// Author: fruit.com.cn + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/content_category.dart'; +import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/models/page_result.dart'; +import 'package:fruitshorts/core/data/services/all_dramas_controller.dart'; + +void main() { + test('should_replace_first_page_when_refresh_succeeds', () async { + final remote = _ScriptedRemote( + pages: [ + PageResult(total: 2, list: [_drama('a'), _drama('b')]), + ], + ); + final controller = AllDramasController(api: remote); + + await controller.refresh(); + + expect(controller.dramas.map((item) => item.id), ['a', 'b']); + expect(controller.hasMore, isFalse); + expect(controller.error, isNull); + expect(remote.pagesRequested, [1]); + }); + + test('should_append_next_page_when_more_dramas_remain', () async { + final remote = _ScriptedRemote( + pages: [ + PageResult(total: 3, list: [_drama('a'), _drama('b')]), + PageResult(total: 3, list: [_drama('c')]), + ], + ); + final controller = AllDramasController(api: remote); + await controller.refresh(); + + await controller.loadMore(); + + expect(controller.dramas.map((item) => item.id), ['a', 'b', 'c']); + expect(controller.hasMore, isFalse); + expect(remote.pagesRequested, [1, 2]); + }); + + test('should_keep_loaded_dramas_when_next_page_fails', () async { + final remote = _ScriptedRemote( + pages: [PageResult(total: 3, list: [_drama('a')])], + failOnPage: 2, + ); + final controller = AllDramasController(api: remote); + await controller.refresh(); + + await controller.loadMore(); + + expect(controller.dramas.map((item) => item.id), ['a']); + expect(controller.error, isNotNull); + }); + + test('should_clear_selected_type_when_all_is_chosen', () { + final controller = AllDramasController.preview( + categories: const [ContentCategory(id: '2', name: '甜宠')], + ); + + controller.toggleCategory('2'); + controller.toggleCategory(''); + + expect(controller.selectedCategories, isEmpty); + }); + + test('should_send_selected_ids_when_loading_a_page', () async { + final remote = _ScriptedRemote( + pages: [PageResult(total: 0, list: const [])], + ); + final controller = AllDramasController(api: remote); + controller.toggleCategory('2'); + controller.toggleClassification('3'); + controller.toggleTag('4'); + + await controller.refresh(); + + expect(remote.lastCategoryIds, ['2']); + expect(remote.lastClassificationIds, ['3']); + expect(remote.lastTagIds, ['4']); + }); +} + +Drama _drama(String id) { + return Drama(id: id, title: id, synopsis: '', posterUrl: ''); +} + +class _ScriptedRemote implements AllDramasRemote { + _ScriptedRemote({required this.pages, this.failOnPage}); + + final List> pages; + final int? failOnPage; + final List pagesRequested = []; + List lastCategoryIds = const []; + List lastClassificationIds = const []; + List lastTagIds = const []; + + @override + Future> classifications() async => const [ + ContentCategory(id: '3', name: '精选'), + ]; + + @override + Future> categories() async => const [ + ContentCategory(id: '2', name: '甜宠'), + ]; + + @override + Future> tags() async => const [ + ContentCategory(id: '4', name: '热剧'), + ]; + + @override + Future> dramas({ + required List categoryIds, + required List classificationIds, + required List tagIds, + required int pageIndex, + }) async { + lastCategoryIds = categoryIds; + lastClassificationIds = classificationIds; + lastTagIds = tagIds; + pagesRequested.add(pageIndex); + if (pageIndex == failOnPage) throw StateError('page failed'); + return pages[pageIndex - 1]; + } +} diff --git a/test/core/playlist_controller_test.dart b/test/core/playlist_controller_test.dart new file mode 100644 index 0000000..7898e25 --- /dev/null +++ b/test/core/playlist_controller_test.dart @@ -0,0 +1,65 @@ +// Author: fruit.com.cn + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/page_result.dart'; +import 'package:fruitshorts/core/data/models/playlist_detail.dart'; +import 'package:fruitshorts/core/data/repositories/playlist_repository.dart'; +import 'package:fruitshorts/core/data/services/playlist_controller.dart'; + +void main() { + test('should_append_the_next_page_when_more_playlists_remain', () async { + final repository = _PagedPlaylists(total: 12); + final controller = PlaylistController(repository); + + await controller.load(); + expect(controller.items, hasLength(PlaylistController.pageSize)); + expect(controller.hasMore, isTrue); + + await controller.loadMore(); + expect(controller.items, hasLength(12)); + expect(controller.items.last.id, '11'); + expect(controller.hasMore, isFalse); + + controller.dispose(); + }); +} + +class _PagedPlaylists implements PlaylistRepository { + _PagedPlaylists({required this.total}); + + final int total; + + @override + Future> fetchPlaylists({ + int pageIndex = 1, + int pageSize = 10, + }) async { + final start = (pageIndex - 1) * pageSize; + if (start >= total) { + return PageResult(total: total); + } + final end = start + pageSize > total ? total : start + pageSize; + return PageResult( + total: total, + list: [ + for (var index = start; index < end; index++) + PlaylistDetail( + id: '$index', + name: '剧单$index', + description: '', + dramaCount: 0, + dramas: const [], + ), + ], + ); + } + + @override + Future fetchPlaylist( + String id, { + int pageIndex = 1, + int pageSize = 20, + }) { + throw UnimplementedError(); + } +} diff --git a/test/core/playlist_detail_test.dart b/test/core/playlist_detail_test.dart new file mode 100644 index 0000000..e8c9a02 --- /dev/null +++ b/test/core/playlist_detail_test.dart @@ -0,0 +1,42 @@ +// Author: fruit.com.cn + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/playlist_detail.dart'; + +void main() { + test('should_parse_playlist_cards_when_detail_json_arrives', () { + final playlist = PlaylistDetail.fromJson({ + 'id': '2112807182243336196', + 'name': '本周必看', + 'locales': {'zh': '本周必看', 'en': 'Weekly Hits', 'id': 'Hits Mingguan'}, + 'description': '编辑精选', + 'descriptionLocales': { + 'zh': '编辑精选', + 'en': 'Editor picks', + 'id': 'Pilihan editor', + }, + 'dramaCount': 1, + 'list': [ + { + 'id': '5', + 'title': '命运的相遇', + 'cover': '/files/cover.jpg', + 'synopsis': '一次意外', + 'totalEpisodes': 12, + 'viewCount': '20', + }, + ], + }); + + expect(playlist.id, '2112807182243336196'); + expect(playlist.name, '本周必看'); + expect(playlist.description, '编辑精选'); + expect(playlist.localizedName('en'), 'Weekly Hits'); + expect(playlist.localizedDescription('id'), 'Pilihan editor'); + expect(playlist.localizedName('zh'), '本周必看'); + expect(playlist.dramaCount, 1); + expect(playlist.dramas.single.title, '命运的相遇'); + expect(playlist.dramas.single.posterUrl, '/files/cover.jpg'); + expect(playlist.dramas.single.totalEpisodes, 12); + }); +} diff --git a/test/discover/home_all_dramas_grid_test.dart b/test/discover/home_all_dramas_grid_test.dart index b55d0f6..451b340 100644 --- a/test/discover/home_all_dramas_grid_test.dart +++ b/test/discover/home_all_dramas_grid_test.dart @@ -14,6 +14,7 @@ import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart import 'package:fruitshorts/core/theme/app_colors.dart'; import 'package:fruitshorts/core/widgets/app_pull_to_refresh.dart'; import 'package:fruitshorts/pages/discover/discover_view.dart'; +import 'package:fruitshorts/pages/discover/widgets/discover_category_tab.dart'; import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; diff --git a/test/home/home_card_style_test.dart b/test/home/home_card_style_test.dart index 551cfd0..de11cf0 100644 --- a/test/home/home_card_style_test.dart +++ b/test/home/home_card_style_test.dart @@ -21,6 +21,8 @@ void main() { title: 'A very long home grid drama title that must wrap', dramaType: 'ANIME', viewCount: 444500, + tag: '漫剧', + tagLocales: const {'zh': '漫剧', 'en': 'Anime', 'id': 'Anime'}, ), onTap: () => taps++, ); @@ -52,7 +54,7 @@ void main() { BorderRadius.circular(AppDimens.cardCoverRadius), ); final badge = find.byKey( - const Key('drama-cover-badge-ribbon-anime-card-anime'), + const Key('drama-cover-badge-ribbon-anime-card-tag'), ); expect(badge, findsOneWidget); expect( @@ -120,10 +122,7 @@ void main() { expect(taps, 1); }); - testWidgets('should_choose_anime_new_and_hot_badges_from_real_data', ( - tester, - ) async { - final now = DateTime(2026, 10, 22, 12); + testWidgets('should_show_cover_badge_from_tag_locale', (tester) async { await tester.pumpWidget( MaterialApp( theme: AppTheme.dark(), @@ -134,18 +133,18 @@ void main() { body: Row( children: [ for (final drama in [ - _drama(id: 'anime', title: 'Anime', dramaType: 'ANIME'), _drama( - id: 'new', - title: 'New', - createdAt: now.subtract(const Duration(days: 2)), + id: 'tagged', + title: 'Tagged', + tag: '新剧', + tagLocales: const {'zh': '新剧', 'en': 'New', 'id': 'Baru'}, ), - _drama(id: 'hot', title: 'Hot', isHot: true), + _drama(id: 'plain', title: 'Plain'), ]) SizedBox( width: 130, height: 240, - child: HomeFeedCard(drama: drama, onTap: () {}, now: now), + child: HomeFeedCard(drama: drama, onTap: () {}), ), ], ), @@ -154,18 +153,10 @@ void main() { ); await tester.pumpAndSettle(); - expect( - find.byKey(const Key('drama-cover-badge-ribbon-anime-anime')), - findsOneWidget, - ); - expect( - find.byKey(const Key('drama-cover-badge-ribbon-new-newRelease')), - findsOneWidget, - ); - expect( - find.byKey(const Key('drama-cover-badge-ribbon-hot-hot')), - findsOneWidget, - ); + final badge = find.byKey(const Key('drama-cover-badge-ribbon-tagged-tag')); + expect(badge, findsOneWidget); + expect(find.text('New'), findsOneWidget); + expect(find.byKey(const Key('drama-cover-badge-ribbon-plain-tag')), findsNothing); }); } @@ -176,6 +167,8 @@ Drama _drama({ int viewCount = 0, bool isHot = false, DateTime? createdAt, + String tag = '', + Map tagLocales = const {}, }) { return Drama( id: id, @@ -188,6 +181,8 @@ Drama _drama({ isHot: isHot, dramaType: dramaType, createdAt: createdAt, + tag: tag, + tagLocales: tagLocales, ); } @@ -210,7 +205,6 @@ Future _pumpCard( child: HomeFeedCard( drama: drama, onTap: onTap, - now: DateTime(2026, 10, 22, 12), ), ), ), diff --git a/test/home/home_grid_layout_test.dart b/test/home/home_grid_layout_test.dart index 54509ac..89505c1 100644 --- a/test/home/home_grid_layout_test.dart +++ b/test/home/home_grid_layout_test.dart @@ -45,24 +45,24 @@ void main() { await _pumpApp(tester, repository); final sectionKeys = [ - const Key('home-section-featured'), - const Key('home-section-new'), - const Key('home-section-categories'), - const Key('home-section-ranking'), const Key('home-section-category-古装'), const Key('home-section-category-社群'), + const Key('home-section-category-爽剧'), + const Key('home-section-category-甜宠'), + const Key('home-section-category-打脸虐渣'), + const Key('home-section-category-虐恋'), ]; final sectionCenters = sectionKeys .map((key) => tester.getCenter(find.byKey(key)).dx) .toList(); expect(sectionCenters, orderedEquals(sectionCenters.toList()..sort())); expect(find.byKey(const Key('home-secondary-tags')), findsNothing); - expect(find.text('Featured'), findsOneWidget); - expect(find.text('New'), findsOneWidget); - expect(find.text('Categories'), findsOneWidget); - expect(find.text('Charts'), findsOneWidget); - expect(find.text('Costume'), findsOneWidget); - expect(find.text('Community'), findsOneWidget); + expect(find.text('Featured'), findsNothing); + expect(find.text('New'), findsNothing); + expect(find.text('Categories'), findsNothing); + expect(find.text('Charts'), findsNothing); + expect(find.text('古装'), findsOneWidget); + expect(find.text('社群'), findsOneWidget); expect(find.byKey(const Key('mine-mini-player')), findsNothing); expect(find.byType(FloatingActionButton), findsNothing); }); @@ -94,31 +94,9 @@ void main() { } expect(find.byKey(const Key('home-grid')), findsOneWidget); + expect(find.byKey(const Key('home-section-featured')), findsNothing); expect(firstDramaId(), 'page1-0'); - await tester.tap(find.byKey(const Key('home-section-new'))); - await tester.pumpAndSettle(); - expect(find.byKey(const Key('new-releases-page')), findsNothing); - expect(find.byKey(const Key('home-secondary-tags')), findsNothing); - expect(firstDramaId(), 'page1-9'); - - await tester.tap(find.byKey(const Key('home-section-ranking'))); - await tester.pumpAndSettle(); - expect(find.byKey(const Key('ranking-page')), findsOneWidget); - expect(find.byKey(const Key('ranking-hero')), findsOneWidget); - expect(find.byKey(const Key('home-grid-cards')), findsNothing); - final home = tester.widget(find.byKey(const Key('home-grid'))); - expect( - (home.decoration as BoxDecoration).gradient, - AppTheme.pageGradient, - ); - - await tester.tap(find.byKey(const Key('home-section-categories'))); - await tester.pumpAndSettle(); - expect(find.byKey(const Key('filter-page')), findsNothing); - expect(find.byKey(const Key('home-secondary-tags')), findsNothing); - expect(find.byKey(const Key('home-grid-cards')), findsOneWidget); - await tester.tap(find.byKey(const Key('home-section-category-古装'))); await tester.pumpAndSettle(); expect(firstDramaId(), 'page1-0'); @@ -149,9 +127,9 @@ void main() { final before = tester.state(scrollable).position.pixels; expect(before, greaterThan(0)); - await tester.tap(find.byKey(const Key('home-section-new'))); + await tester.tap(find.byKey(const Key('home-section-category-社群'))); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('home-section-featured'))); + await tester.tap(find.byKey(const Key('home-section-category-古装'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('home-secondary-tags')), findsNothing); @@ -180,7 +158,7 @@ void main() { expect(find.byKey(const Key('home-secondary-tags')), findsNothing); final selected = tester.widget( - find.byKey(const Key('home-primary-label-newReleases')), + find.byKey(const Key('home-primary-label-community')), ); expect(selected.style.fontSize, AppDimens.homePrimaryTabSelectedFont); expect(selected.style.fontWeight, FontWeight.w700); diff --git a/test/home/home_preview_layout_test.dart b/test/home/home_preview_layout_test.dart index 901e7bf..0c667ac 100644 --- a/test/home/home_preview_layout_test.dart +++ b/test/home/home_preview_layout_test.dart @@ -87,10 +87,7 @@ void main() { expect(delegate.crossAxisCount, 3); expect(delegate.crossAxisSpacing, AppDimens.homeGridGap); expect(delegate.mainAxisSpacing, AppDimens.homeGridRowGap); - expect(find.byKey(const Key('home-section-featured')), findsOneWidget); - expect(find.byKey(const Key('home-section-new')), findsOneWidget); - expect(find.byKey(const Key('home-section-categories')), findsOneWidget); - expect(find.byKey(const Key('home-section-ranking')), findsOneWidget); + expect(find.byKey(const Key('home-section-featured')), findsNothing); expect(find.byKey(const Key('home-section-category-古装')), findsOneWidget); expect(find.byKey(const Key('home-section-category-社群')), findsOneWidget); expect(find.byKey(const Key('home-secondary-tags')), findsNothing); @@ -111,10 +108,10 @@ void main() { expect(placeholder.style?.fontSize, AppDimens.searchPlaceholderFont); expect(placeholder.style?.color, AppColors.textSecondary); final selectedPrimary = tester.widget( - find.byKey(const Key('home-primary-label-featured')), + find.byKey(const Key('home-primary-label-costume')), ); final unselectedPrimary = tester.widget( - find.byKey(const Key('home-primary-label-newReleases')), + find.byKey(const Key('home-primary-label-community')), ); expect( selectedPrimary.style.fontSize, @@ -128,19 +125,19 @@ void main() { expect(unselectedPrimary.style.color, AppColors.textTabInactive); expect( find.descendant( - of: find.byKey(const Key('home-section-featured')), + of: find.byKey(const Key('home-section-category-古装')), matching: find.byType(Container), ), findsNothing, ); - final primaryStart = tester.getTopLeft(find.text('Featured')); + final primaryStart = tester.getTopLeft(find.text('古装')); final pageStart = tester.getTopLeft(searchBox); expect(primaryStart.dx, pageStart.dx); final primaryGap = - tester.getTopLeft(find.text('New')).dx - - tester.getBottomRight(find.text('Featured')).dx; + tester.getTopLeft(find.text('社群')).dx - + tester.getBottomRight(find.text('古装')).dx; expect(primaryGap, closeTo(AppDimens.homePrimaryCategoryGap, 0.1)); - expect(find.byType(PageView), findsNothing); + expect(find.byKey(const Key('home-primary-page-view')), findsOneWidget); expect(tester.takeException(), isNull); }); diff --git a/test/home/k28_a_navigation_test.dart b/test/home/k28_a_navigation_test.dart index ae8b830..6d0a1e9 100644 --- a/test/home/k28_a_navigation_test.dart +++ b/test/home/k28_a_navigation_test.dart @@ -108,7 +108,7 @@ void main() { await tester.tap(find.byKey(const Key('recommend-tab-all'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('all-dramas-grid')), findsOneWidget); - for (var row = 0; row < 4; row++) { + for (var row = 0; row < 3; row++) { expect(find.byKey(Key('all-filter-row-$row')), findsOneWidget); } _expectNoFloatingOverlays(); diff --git a/test/recommend/all_dramas_filter_alignment_test.dart b/test/recommend/all_dramas_filter_alignment_test.dart index 000aa1b..e145735 100644 --- a/test/recommend/all_dramas_filter_alignment_test.dart +++ b/test/recommend/all_dramas_filter_alignment_test.dart @@ -2,7 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:fruitshorts/core/data/services/home_feed_controller.dart'; +import 'package:fruitshorts/core/data/models/content_category.dart'; +import 'package:fruitshorts/core/data/services/all_dramas_controller.dart'; import 'package:fruitshorts/core/theme/app_dimens.dart'; import 'package:fruitshorts/l10n/app_localizations.dart'; import 'package:fruitshorts/pages/recommend/widgets/all_dramas_filter_grid.dart'; @@ -12,9 +13,21 @@ import '../support/fake_catalog.dart'; void main() { testWidgets('all filter rows start at the same left edge', (tester) async { final catalog = FakeCatalogRepository(); - final controller = HomeFeedController(catalog) - ..seedRecommend(catalog.dramas); - addTearDown(controller.dispose); + final controller = AllDramasController.preview( + classifications: const [ + ContentCategory(id: 'local', name: 'Local'), + ContentCategory(id: 'asia', name: 'Asia'), + ], + categories: const [ + ContentCategory(id: 'subtitles', name: 'Subtitles'), + ContentCategory(id: 'dubbed', name: 'Dubbed'), + ], + tags: const [ + ContentCategory(id: 'reversal', name: 'Reversal'), + ContentCategory(id: 'uplifting', name: 'Uplifting'), + ], + dramas: catalog.dramas, + ); await tester.pumpWidget( MaterialApp( @@ -23,7 +36,7 @@ void main() { localizationsDelegates: AppLocalizations.localizationsDelegates, home: Scaffold( body: AllDramasFilterGrid( - feedController: controller, + controller: controller, onOpenDrama: (_) {}, ), ), @@ -33,19 +46,9 @@ void main() { expect(AppDimens.allDramasHorizontal, 12); final firstChips = [ - find.descendant( - of: find.byKey(const Key('all-filter-row-0')), - matching: find.byKey(const Key('all-filter-all')), - ), - find.descendant( - of: find.byKey(const Key('all-filter-row-1')), - matching: find.byKey(const Key('all-filter-all')), - ), - find.descendant( - of: find.byKey(const Key('all-filter-row-2')), - matching: find.byKey(const Key('all-filter-all')), - ), - find.byKey(const Key('all-sort-hot')), + find.byKey(const Key('all-filter-classification-all')), + find.byKey(const Key('all-filter-category-all')), + find.byKey(const Key('all-filter-tag-all')), ]; final starts = firstChips .map((finder) => tester.getTopLeft(finder).dx) @@ -57,9 +60,9 @@ void main() { } final rowZeroChips = [ - find.byKey(const Key('all-filter-all')).first, - find.byKey(const Key('all-filter-local')), - find.byKey(const Key('all-filter-asia')), + find.byKey(const Key('all-filter-classification-all')), + find.byKey(const Key('all-filter-classification-local')), + find.byKey(const Key('all-filter-classification-asia')), ]; final rowZeroRects = rowZeroChips.map(tester.getRect).toList(); final rowZeroGaps = [ @@ -69,12 +72,9 @@ void main() { expect(rowZeroGaps[0], closeTo(10, 0.5)); expect(rowZeroGaps[1], closeTo(rowZeroGaps[0], 0.5)); - final rowOneAll = find.descendant( - of: find.byKey(const Key('all-filter-row-1')), - matching: find.byKey(const Key('all-filter-all')), - ); - final rowOneSubtitles = find.byKey(const Key('all-filter-subtitles')); - final rowOneDubbed = find.byKey(const Key('all-filter-dubbed')); + final rowOneAll = find.byKey(const Key('all-filter-category-all')); + final rowOneSubtitles = find.byKey(const Key('all-filter-category-subtitles')); + final rowOneDubbed = find.byKey(const Key('all-filter-category-dubbed')); final rowOneGap = tester.getTopLeft(rowOneSubtitles).dx - tester.getTopRight(rowOneAll).dx; @@ -85,12 +85,9 @@ void main() { closeTo(rowOneGap, 0.5), ); - final rowTwoAll = find.descendant( - of: find.byKey(const Key('all-filter-row-2')), - matching: find.byKey(const Key('all-filter-all')), - ); - final rowTwoReversal = find.byKey(const Key('all-filter-reversal')); - final rowTwoUplifting = find.byKey(const Key('all-filter-uplifting')); + final rowTwoAll = find.byKey(const Key('all-filter-tag-all')); + final rowTwoReversal = find.byKey(const Key('all-filter-tag-reversal')); + final rowTwoUplifting = find.byKey(const Key('all-filter-tag-uplifting')); final rowTwoGap = tester.getTopLeft(rowTwoReversal).dx - tester.getTopRight(rowTwoAll).dx; expect(rowTwoGap, closeTo(10, 0.5)); @@ -99,11 +96,5 @@ void main() { tester.getTopRight(rowTwoReversal).dx, closeTo(rowTwoGap, 0.5), ); - - expect( - tester.getTopLeft(find.byKey(const Key('all-sort-new'))).dx - - tester.getTopRight(find.byKey(const Key('all-sort-hot'))).dx, - closeTo(10, 0.5), - ); }); } diff --git a/test/recommend/recommend_sections_test.dart b/test/recommend/recommend_sections_test.dart index 3a7f8b6..5868e65 100644 --- a/test/recommend/recommend_sections_test.dart +++ b/test/recommend/recommend_sections_test.dart @@ -34,7 +34,7 @@ void main() { expect(find.byKey(const Key('recommend-tab-recommend')), findsOneWidget); expect(find.byKey(const Key('recommend-tab-playlist')), findsOneWidget); expect(find.byKey(const Key('recommend-tab-all')), findsOneWidget); - expect(find.byKey(const Key('recommend-feed-player')), findsOneWidget); + expect(find.byKey(const Key('recommend-feed-player'), skipOffstage: false), findsOneWidget); expect(find.byKey(const Key('recommend-top-gradient')), findsOneWidget); final recommendText = tester.widget( find.descendant( @@ -81,12 +81,12 @@ void main() { tester .getRect(find.byKey(const Key('recommend-top-nav'))) .overlaps( - tester.getRect(find.byKey(const Key('recommend-feed-player'))), + tester.getRect(find.byKey(const Key('recommend-feed-player'), skipOffstage: false)), ), isTrue, ); final player = tester.widget( - find.byKey(const Key('recommend-feed-player')), + find.byKey(const Key('recommend-feed-player'), skipOffstage: false), ); expect(player.homeMode, isTrue); expect(player.embedded, isTrue); @@ -227,15 +227,9 @@ void main() { ); expect(viewIcon, findsOneWidget); final badge = find.byKey( - const Key('drama-cover-badge-ribbon-hits-the-signal-demo-hot'), + const Key('drama-cover-badge-ribbon-hits-the-signal-demo-tag'), ); - expect(badge, findsOneWidget); - final badgeText = tester.widget( - find.descendant(of: badge, matching: find.byType(Text)), - ); - expect(badgeText.data, 'Hit'); - expect(badgeText.style?.fontSize, AppDimens.cardBadgeFont); - expect(badgeText.style?.color, AppColors.textPrimary); + expect(badge, findsNothing); final tagsFinder = find.byKey( const Key('recommend-tags-hits-the-signal-demo'), ); @@ -309,7 +303,7 @@ void main() { expect(delegate.crossAxisCount, 3); expect(delegate.crossAxisSpacing, AppDimens.allDramasGridGap); expect(delegate.mainAxisSpacing, AppDimens.allDramasGridRowGap); - for (var row = 0; row < 4; row++) { + for (var row = 0; row < 3; row++) { expect(find.byKey(Key('all-filter-row-$row')), findsOneWidget); final scroll = tester.widget( find.byKey(Key('all-filter-scroll-$row')), @@ -318,7 +312,7 @@ void main() { expect(scroll.physics, isA()); } final filterStarts = [ - for (var row = 0; row < 4; row++) + for (var row = 0; row < 3; row++) tester.getRect(find.byKey(Key('all-filter-row-$row'))).left, ]; expect( @@ -326,7 +320,7 @@ void main() { everyElement(closeTo(AppDimens.allDramasHorizontal, 0.1)), ); final pageRect = tester.getRect(find.byKey(const Key('recommend-page'))); - for (var row = 0; row < 4; row++) { + for (var row = 0; row < 3; row++) { final rowRect = tester.getRect(find.byKey(Key('all-filter-row-$row'))); expect( rowRect.left, @@ -347,10 +341,6 @@ void main() { of: find.byKey(const Key('all-filter-row-2')), matching: find.byType(Text), ), - find.descendant( - of: find.byKey(const Key('all-filter-row-3')), - matching: find.byType(Text), - ), ]; final firstTextX = firstTexts .map((finder) => tester.getTopLeft(finder.first).dx) @@ -365,14 +355,14 @@ void main() { 0.1, ), ); - for (var row = 0; row < 3; row++) { + for (var row = 0; row < 2; row++) { expect( tester.getSize(find.byKey(Key('all-filter-row-$row'))).height, AppDimens.filterChipHeight + AppDimens.filterRowGap, ); } expect( - tester.getSize(find.byKey(const Key('all-filter-row-3'))).height, + tester.getSize(find.byKey(const Key('all-filter-row-2'))).height, AppDimens.filterChipHeight, ); expect( @@ -406,112 +396,31 @@ void main() { tester.getRect(find.byKey(const Key('all-dramas-filters'))).top, closeTo(fixedFilterTop, 0.1), ); - expect(find.byKey(const Key('all-filter-local')), findsOneWidget); - expect(_textStyle(tester, 'Local').color, AppColors.filterChipTextInactive); - expect(find.byKey(const Key('all-filter-subtitles')), findsOneWidget); - for (final key in [ - 'all-filter-reversal', - 'all-filter-uplifting', - 'all-filter-modern', - 'all-filter-sweet', - 'all-filter-angst', - ]) { - expect(find.byKey(Key(key)), findsOneWidget); - } - final rowZero = find.byKey(const Key('all-filter-row-0')); - final rowZeroAll = find.descendant( - of: rowZero, - matching: find.byKey(const Key('all-filter-all')), - ); - final rowZeroLocal = find.descendant( - of: rowZero, - matching: find.byKey(const Key('all-filter-local')), - ); + expect(find.byKey(const Key('all-filter-classification-all')), findsOneWidget); + expect(find.byKey(const Key('all-filter-category-all')), findsOneWidget); + expect(find.byKey(const Key('all-filter-tag-all')), findsOneWidget); expect( - tester.getTopLeft(rowZeroAll).dx, - closeTo(pageRect.left + AppDimens.allDramasHorizontal, 0.1), - ); - expect( - tester.getTopLeft(rowZeroLocal).dx - tester.getTopRight(rowZeroAll).dx, - AppDimens.filterItemGap, - ); - final rowOne = find.byKey(const Key('all-filter-row-1')); - final rowTwo = find.byKey(const Key('all-filter-row-2')); - for (final row in [rowOne, rowTwo]) { - final all = find.descendant( - of: row, - matching: find.byKey(const Key('all-filter-all')), - ); - final next = find.descendant( - of: row, - matching: row == rowOne - ? find.byKey(const Key('all-filter-subtitles')) - : find.byKey(const Key('all-filter-reversal')), - ); - expect( - tester.getTopLeft(next).dx - tester.getTopRight(all).dx, - AppDimens.filterItemGap, - ); - } - expect( - tester.getTopLeft(find.byKey(const Key('all-sort-new'))).dx - - tester.getTopRight(find.byKey(const Key('all-sort-hot'))).dx, - AppDimens.filterItemGap, - ); - await tester.tap(find.byKey(const Key('all-filter-local'))); - await tester.pumpAndSettle(); - expect(find.byKey(const Key('all-dramas-empty')), findsOneWidget); - expect(_textStyle(tester, 'Local').fontWeight, FontWeight.w600); - expect(_textStyle(tester, 'Local').fontSize, AppDimens.filterChipFont); - expect(_textStyle(tester, 'Local').color, AppColors.filterChipTextSelected); - final selectedChip = tester.widget( - find - .descendant( - of: find.byKey(const Key('all-filter-local')), - matching: find.byType(Container), + tester + .widget( + find.descendant( + of: find.byKey(const Key('all-filter-classification-all')), + matching: find.text('All'), + ), ) - .first, - ); - expect( - (selectedChip.decoration! as BoxDecoration).color, - AppColors.filterChipSelected, - ); - expect( - tester.getSize(find.byKey(const Key('all-filter-local'))).height, - AppDimens.filterChipHeight, - ); - await tester.tap(find.byKey(const Key('all-filter-all')).first); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('all-sort-new'))); - await tester.pump(); - expect(_textStyle(tester, 'New').fontWeight, FontWeight.w600); - expect(_textStyle(tester, 'New').fontSize, AppDimens.filterChipFont); - expect(_textStyle(tester, 'New').color, AppColors.filterChipTextSelected); - expect( - tester.getSize(find.byKey(const Key('all-sort-new'))).height, - AppDimens.filterChipHeight, - ); - final chipContainer = tester.widget( - find.descendant( - of: find.byKey(const Key('all-sort-new')), - matching: find.byType(Container), - ), - ); - expect( - (chipContainer.decoration! as BoxDecoration).borderRadius, - BorderRadius.circular(AppDimens.filterChipRadius), + .style + ?.color, + AppColors.filterChipTextSelected, ); expect(find.byKey(const Key('all-dramas-filter-page')), findsNothing); await tester.tap(find.byKey(const Key('recommend-tab-playlist'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('recommend-section-hits')), findsOneWidget); - expect(find.byKey(const Key('recommend-feed-player')), findsOneWidget); + expect(find.byKey(const Key('recommend-feed-player'), skipOffstage: false), findsOneWidget); expect(find.byType(AppBottomNav), findsOneWidget); await tester.tap(find.byKey(const Key('recommend-tab-all'))); await tester.pumpAndSettle(); - expect(_textStyle(tester, 'New').fontWeight, FontWeight.w700); expect(find.byKey(const Key('all-dramas-grid')), findsOneWidget); }); @@ -527,7 +436,7 @@ void main() { await tester.drag(pages, const Offset(-320, 0)); await tester.pumpAndSettle(); expect(find.byKey(const Key('recommend-section-hits')), findsOneWidget); - expect(find.byKey(const Key('recommend-feed-player')), findsOneWidget); + expect(find.byKey(const Key('recommend-feed-player'), skipOffstage: false), findsOneWidget); expect( tester .widget( @@ -551,7 +460,7 @@ void main() { await tester.drag(pages, const Offset(640, 0)); await tester.pumpAndSettle(); - expect(find.byKey(const Key('recommend-feed-player')), findsOneWidget); + expect(find.byKey(const Key('recommend-feed-player'), skipOffstage: false), findsOneWidget); expect(find.byKey(const Key('recommend-tab-recommend')), findsOneWidget); }); @@ -585,7 +494,7 @@ void main() { await _pumpRecommend(tester); final gesture = await tester.startGesture( - tester.getCenter(find.byKey(const Key('recommend-feed-player'))), + tester.getCenter(find.byKey(const Key('recommend-feed-player'), skipOffstage: false)), ); await gesture.moveBy(const Offset(0, 80)); await tester.pump(); @@ -593,7 +502,7 @@ void main() { final feedScroll = tester.state( find .descendant( - of: find.byKey(const Key('recommend-feed-player')), + of: find.byKey(const Key('recommend-feed-player'), skipOffstage: false), matching: find.byType(Scrollable), ) .first, @@ -621,7 +530,8 @@ void main() { final player = tester.widget(find.byType(PlayerPage).last); expect(player.homeMode, isFalse); await tester.binding.handlePopRoute(); - await tester.pumpAndSettle(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); await tester.tap(find.byKey(const Key('recommend-search'))); await tester.pumpAndSettle(); @@ -632,17 +542,6 @@ void main() { }); } -TextStyle _textStyle(WidgetTester tester, String text) { - return tester - .widget( - find.descendant( - of: find.byKey(const Key('all-sort-new')), - matching: find.text(text), - ), - ) - .style!; -} - Future _pumpRecommend(WidgetTester tester) async { tester.view.physicalSize = const Size(390, 844); tester.view.devicePixelRatio = 1;