diff --git a/android/app/src/main/res/drawable-hdpi/android12splash.png b/android/app/src/main/res/drawable-hdpi/android12splash.png index e72a885..29fa6b4 100644 Binary files a/android/app/src/main/res/drawable-hdpi/android12splash.png and b/android/app/src/main/res/drawable-hdpi/android12splash.png differ diff --git a/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png index 389ec52..e4a60c1 100644 Binary files a/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png and b/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-hdpi/splash_screen.png b/android/app/src/main/res/drawable-hdpi/splash_screen.png index d212bc5..0748f71 100644 Binary files a/android/app/src/main/res/drawable-hdpi/splash_screen.png and b/android/app/src/main/res/drawable-hdpi/splash_screen.png differ diff --git a/android/app/src/main/res/drawable-mdpi/android12splash.png b/android/app/src/main/res/drawable-mdpi/android12splash.png index 391d362..f053f0c 100644 Binary files a/android/app/src/main/res/drawable-mdpi/android12splash.png and b/android/app/src/main/res/drawable-mdpi/android12splash.png differ diff --git a/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png index f3651f9..4ba52f4 100644 Binary files a/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png and b/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-mdpi/splash_screen.png b/android/app/src/main/res/drawable-mdpi/splash_screen.png index 96302e8..2ff2a33 100644 Binary files a/android/app/src/main/res/drawable-mdpi/splash_screen.png and b/android/app/src/main/res/drawable-mdpi/splash_screen.png differ diff --git a/android/app/src/main/res/drawable-night-v21/launch_background.xml b/android/app/src/main/res/drawable-night-v21/launch_background.xml index 783bcaf..35eaddd 100644 --- a/android/app/src/main/res/drawable-night-v21/launch_background.xml +++ b/android/app/src/main/res/drawable-night-v21/launch_background.xml @@ -2,18 +2,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - #000000 - #E9142E - + #000000 + \ No newline at end of file diff --git a/assets/icon/splash_logo.png b/assets/icon/splash_logo.png index 9b81b50..17b01cf 100644 Binary files a/assets/icon/splash_logo.png and b/assets/icon/splash_logo.png differ diff --git a/assets/icon/ymb.png b/assets/icon/ymb.png index 104b093..20fb75b 100644 Binary files a/assets/icon/ymb.png and b/assets/icon/ymb.png differ diff --git a/assets/icon/ymb_android12.png b/assets/icon/ymb_android12.png index 7cd718d..9547a0c 100644 Binary files a/assets/icon/ymb_android12.png and b/assets/icon/ymb_android12.png differ diff --git a/assets/icon/ymb_foreground.png b/assets/icon/ymb_foreground.png index 513ab0a..a730a3a 100644 Binary files a/assets/icon/ymb_foreground.png and b/assets/icon/ymb_foreground.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png index 104b093..b289052 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png index 35474e4..56bc084 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png index 1e779a6..45f07dd 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png index e3ad8c6..4a26583 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png index 08f2dc9..953f359 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png index f67a16d..1b6c2d2 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png index f449abb..f22bab9 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png index 1e779a6..45f07dd 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png index bd475db..59346a6 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png index 4a88a44..ce5d937 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png index 233ba2b..43c0b17 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png index 2de1e44..0173ff2 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png index 8670304..2b3a496 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png index b2814f4..fc806c2 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png index 4a88a44..ce5d937 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png index bab729a..4420df6 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png index 4da487a..faf6f56 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png index 8eb9510..d1f6548 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png index 4d0c5c6..493a4d8 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png index 8cb7273..aeb84bb 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png index 6750d9c..a4a084d 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png index 3ddcfe7..8d70895 100644 Binary files a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png index 0ac3e43..b227e50 100644 Binary files a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png index 08a310c..d4aaf45 100644 Binary files a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/lib/app.dart b/lib/app.dart index 8d547c4..47ea1e3 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -268,7 +268,9 @@ class _LumaDramaAppState extends State { CategoryController _createCategories() { final controller = CategoryController(_categoryRepository); if (widget.catalogRepository == null) { - controller.dramaPageLoader = CategoryApi(_apiClient).dramas; + final api = CategoryApi(_apiClient); + controller.dramaPageLoader = api.dramas; + controller.rankingLoader = api.ranking; } controller.load(); return controller; diff --git a/lib/core/data/models/category_ranking.dart b/lib/core/data/models/category_ranking.dart new file mode 100644 index 0000000..4b73d44 --- /dev/null +++ b/lib/core/data/models/category_ranking.dart @@ -0,0 +1,67 @@ +// Author: fruit.com.cn + +import 'drama.dart'; +import 'locale_text.dart'; + +/// 服务端推荐榜快照;顺序和排名均不可在客户端重算。 +class CategoryRanking { + const CategoryRanking({ + required this.id, + required this.code, + required this.revision, + required this.entries, + this.locales = const {}, + this.generatedAt, + }); + + final String id; + final String code; + final String revision; + final Map locales; + final DateTime? generatedAt; + final List entries; + + /// 解析 `GET /app/categories/{id}/ranking`。 + factory CategoryRanking.fromJson(Map json) { + final rawList = json['list']; + final entries = rawList is List + ? rawList + .whereType() + .map( + (item) => + CategoryRankingEntry.fromJson(item.cast()), + ) + .toList(growable: false) + : const []; + return CategoryRanking( + id: (json['id'] ?? '').toString(), + code: json['code'] as String? ?? '', + revision: (json['revision'] ?? '1').toString(), + locales: localeMap(json['locales']), + generatedAt: DateTime.tryParse(json['generatedAt'] as String? ?? ''), + entries: entries, + ); + } +} + +/// 推荐榜中的一部短剧及其服务端排名。 +class CategoryRankingEntry { + const CategoryRankingEntry({required this.rank, required this.drama}); + + final int rank; + final Drama drama; + + factory CategoryRankingEntry.fromJson(Map json) { + final rawDrama = json['drama']; + return CategoryRankingEntry( + rank: (json['rank'] as num?)?.toInt() ?? 0, + drama: Drama.fromCardJson( + rawDrama is Map + ? rawDrama + : rawDrama is Map + ? rawDrama.cast() + : const {}, + ), + ); + } +} diff --git a/lib/core/data/models/content_category.dart b/lib/core/data/models/content_category.dart index af980a5..3f5a054 100644 --- a/lib/core/data/models/content_category.dart +++ b/lib/core/data/models/content_category.dart @@ -7,10 +7,19 @@ class ContentCategory { const ContentCategory({ required this.id, required this.name, + this.code = '', + this.layoutType = gridLayout, + this.revision = '1', this.locales = const {}, }); + static const String gridLayout = 'GRID'; + static const String rankingLayout = 'RANKING'; + final String id; + final String code; + final String layoutType; + final String revision; /// 中文名称,与 locales 里的 zh 相同。 final String name; @@ -20,9 +29,18 @@ class ContentCategory { factory ContentCategory.fromJson(Map json) { final locales = localeMap(json['locales']); final name = json['name'] as String? ?? locales['zh'] ?? ''; - return ContentCategory(id: (json['id'] ?? '').toString(), name: name, locales: locales); + return ContentCategory( + id: (json['id'] ?? '').toString(), + name: name, + code: json['code'] as String? ?? '', + layoutType: json['layoutType'] as String? ?? gridLayout, + revision: (json['revision'] ?? '1').toString(), + locales: locales, + ); } + bool get isRanking => layoutType == rankingLayout; + /// 按界面语言取名称。缺对应语言时回落到中文。 String localizedName(String languageCode) { return localizedText(languageCode, name, locales); diff --git a/lib/core/data/remote/category_api.dart b/lib/core/data/remote/category_api.dart index 0c4c46c..bc2c9e7 100644 --- a/lib/core/data/remote/category_api.dart +++ b/lib/core/data/remote/category_api.dart @@ -1,6 +1,7 @@ // Author: fruit.com.cn import '../models/content_category.dart'; +import '../models/category_ranking.dart'; import '../models/drama.dart'; import '../models/page_result.dart'; import 'api_client.dart'; @@ -30,11 +31,14 @@ class CategoryApi { }) async { final data = await _client.get( '/app/categories/$categoryId/dramas', - query: { - 'pageIndex': '$pageIndex', - 'pageSize': '$pageSize', - }, + query: {'pageIndex': '$pageIndex', 'pageSize': '$pageSize'}, ); return PageResult.parse(data, Drama.fromCardJson); } + + /// 推荐榜快照,服务端已确定内容顺序和连续排名。 + Future ranking(String categoryId) async { + final data = await _client.get('/app/categories/$categoryId/ranking'); + return CategoryRanking.fromJson(ResponseDecoders.asMap(data)); + } } diff --git a/lib/core/data/services/category_controller.dart b/lib/core/data/services/category_controller.dart index ecd6b87..026635b 100644 --- a/lib/core/data/services/category_controller.dart +++ b/lib/core/data/services/category_controller.dart @@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart'; import '../../config/app_config.dart'; import '../models/content_category.dart'; +import '../models/category_ranking.dart'; import '../models/drama.dart'; import '../models/home_feed.dart'; import '../models/page_result.dart'; @@ -21,10 +22,15 @@ class CategoryController extends ChangeNotifier { String categoryId, { int pageIndex, int pageSize, - })? dramaPageLoader; + })? + dramaPageLoader; + + /// 生产环境推荐榜加载器,测试与本地数据模式可不注入。 + Future Function(String categoryId)? rankingLoader; List categories = const []; final Map _slots = {}; + final Map _rankingSlots = {}; HomeFeed feed = const HomeFeed(); bool loading = false; String? error; @@ -36,6 +42,29 @@ class CategoryController extends ChangeNotifier { return _slots.putIfAbsent(categoryId, CategoryDramaSlot.new); } + CategoryRankingSlot rankingSlotOf(String categoryId) { + return _rankingSlots.putIfAbsent(categoryId, CategoryRankingSlot.new); + } + + /// 打开推荐榜并获取当前服务端快照。 + Future openRanking(String categoryId) async { + final loader = rankingLoader; + if (loader == null) return; + final slot = rankingSlotOf(categoryId); + slot.loading = true; + slot.error = null; + _notify(); + try { + slot.ranking = await loader(categoryId); + } catch (exception) { + slot.error = exception; + if (kDebugMode) debugPrint('[category-ranking] $exception'); + } finally { + slot.loading = false; + _notify(); + } + } + /// 打开分类并加载第一页短剧。 Future openCategory(String categoryId) async { final loader = dramaPageLoader; @@ -126,8 +155,14 @@ class CategoryController extends ChangeNotifier { loading = false; _notify(); } - if (error == null && dramaPageLoader != null && categories.isNotEmpty) { - await openCategory(categories.first.id); + final hasRemoteLoader = dramaPageLoader != null || rankingLoader != null; + if (error == null && hasRemoteLoader && categories.isNotEmpty) { + final first = categories.first; + if (first.isRanking) { + await openRanking(first.id); + } else { + await openCategory(first.id); + } } } @@ -152,3 +187,10 @@ class CategoryDramaSlot { bool get hasMore => dramas.length < total; } + +/// 单个推荐榜的快照状态。 +class CategoryRankingSlot { + CategoryRanking? ranking; + Object? error; + bool loading = false; +} diff --git a/lib/core/data/services/download_controller.dart b/lib/core/data/services/download_controller.dart index b8108e7..c175152 100644 --- a/lib/core/data/services/download_controller.dart +++ b/lib/core/data/services/download_controller.dart @@ -120,7 +120,9 @@ class DownloadController extends ChangeNotifier { String episodeId, { DownloadLicense? license, }) async { - if (_downloading.contains(episodeId)) return const DownloadActionResult(ok: true); + if (_downloading.contains(episodeId)) { + return const DownloadActionResult(ok: true); + } final source = license ?? _licenseFor(episodeId); if (source == null) return const DownloadActionResult(ok: false); _beginDownload(episodeId); @@ -129,7 +131,8 @@ class DownloadController extends ChangeNotifier { final media = await _storage.download( episodeId: episodeId, url: resolved, - onProgress: (received, total) => _onProgress(episodeId, received, total), + onProgress: (received, total) => + _onProgress(episodeId, received, total), ); _localPaths[episodeId] = media.filePath; _progress[episodeId] = 1; diff --git a/lib/core/theme/app_dimens.dart b/lib/core/theme/app_dimens.dart index 7e4e72a..8ebd243 100644 --- a/lib/core/theme/app_dimens.dart +++ b/lib/core/theme/app_dimens.dart @@ -157,17 +157,9 @@ abstract final class AppDimens { /// 首页热播榜。 static const double rankingHeroTitle = 22; static const double rankingHeroSubtitle = 11; - static const double rankingPodiumChampionWidth = 110; - static const double rankingPodiumChampionHeight = 146; - static const double rankingPodiumWidth = 92; - static const double rankingPodiumHeight = 122; - static const double rankingPodiumRadius = 10; - static const double rankingRowHeight = 96; - static const double rankingNumberWidth = 56; - static const double rankingNumberFont = 48; - static const double rankingCoverWidth = 60; - static const double rankingCoverHeight = 80; static const double rankingCoverRadius = 6; + static const double rankingCardCoverWidth = 96; + static const double rankingCardCoverHeight = 128; /// 全部短剧筛选与追剧双列。 static const double filterChipFont = 16; diff --git a/lib/core/widgets/empty_state.dart b/lib/core/widgets/empty_state.dart index 7efd58a..d11ee4e 100644 --- a/lib/core/widgets/empty_state.dart +++ b/lib/core/widgets/empty_state.dart @@ -5,12 +5,12 @@ import 'package:flutter/material.dart'; import '../theme/app_colors.dart'; import '../theme/app_dimens.dart'; -const double _regularIconSize = 72; -const double _compactIconSize = 44; +const double _iconSize = 72; const double _maxContentWidth = 260; const double _regularHorizontal = 24; const double _regularVertical = 28; -const double _compactPad = 16; +const double _compactHorizontal = 16; +const double _compactVertical = 4; const double _regularGap = 20; const double _compactGap = 12; const double _actionMinHeight = 44; @@ -27,6 +27,7 @@ class EmptyState extends StatelessWidget { this.onAction, this.footer, this.compact = false, + this.stretchContent = false, }); final IconData icon; @@ -38,6 +39,9 @@ class EmptyState extends StatelessWidget { final Widget? footer; final bool compact; + /// 让图标和文案占满内容宽度,适用于需要严格居中的宽版空状态。 + final bool stretchContent; + @override Widget build(BuildContext context) { final hasAction = actionLabel != null && onAction != null; @@ -48,28 +52,33 @@ class EmptyState extends StatelessWidget { child: Padding( key: const Key('empty-state-padding'), padding: EdgeInsets.symmetric( - horizontal: compact ? _compactPad : _regularHorizontal, - vertical: compact ? _compactPad : _regularVertical, + horizontal: compact ? _compactHorizontal : _regularHorizontal, + vertical: compact ? _compactVertical : _regularVertical, ), - child: Semantics( - container: true, - label: title, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ExcludeSemantics( - child: _EmptyMark(icon: icon, compact: compact), - ), - SizedBox(height: compact ? _compactGap : _regularGap), - _EmptyCopy( - title: title, - subtitle: subtitle, - actionLabel: hasAction ? actionLabel : null, - actionKey: actionKey, - onAction: hasAction ? onAction : null, - footer: footer, - ), - ], + child: SizedBox( + width: stretchContent ? double.infinity : null, + child: Semantics( + container: true, + label: title, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ExcludeSemantics(child: _EmptyMark(icon: icon)), + SizedBox(height: compact ? _compactGap : _regularGap), + SizedBox( + width: stretchContent ? double.infinity : null, + child: _EmptyCopy( + title: title, + subtitle: subtitle, + actionLabel: hasAction ? actionLabel : null, + actionKey: actionKey, + onAction: hasAction ? onAction : null, + footer: footer, + stretchContent: stretchContent, + ), + ), + ], + ), ), ), ), @@ -80,17 +89,16 @@ class EmptyState extends StatelessWidget { /// 空列表只放一枚大图标,不画线框,也不铺底色。 class _EmptyMark extends StatelessWidget { - const _EmptyMark({required this.icon, required this.compact}); + const _EmptyMark({required this.icon}); final IconData icon; - final bool compact; @override Widget build(BuildContext context) { return Icon( icon, key: const Key('empty-state-icon'), - size: compact ? _compactIconSize : _regularIconSize, + size: _iconSize, color: AppColors.subtle, ); } @@ -104,6 +112,7 @@ class _EmptyCopy extends StatelessWidget { required this.actionKey, required this.onAction, required this.footer, + required this.stretchContent, }); final String title; @@ -112,11 +121,15 @@ class _EmptyCopy extends StatelessWidget { final Key? actionKey; final VoidCallback? onAction; final Widget? footer; + final bool stretchContent; @override Widget build(BuildContext context) { return Column( mainAxisSize: MainAxisSize.min, + crossAxisAlignment: stretchContent + ? CrossAxisAlignment.stretch + : CrossAxisAlignment.center, children: [ ExcludeSemantics( child: Text( diff --git a/lib/core/widgets/image_placeholder_shimmer.dart b/lib/core/widgets/image_placeholder_shimmer.dart deleted file mode 100644 index 7956e0f..0000000 --- a/lib/core/widgets/image_placeholder_shimmer.dart +++ /dev/null @@ -1,86 +0,0 @@ -// Author: fruit.com.cn -// 职责:给默认图片加载占位叠加可暂停、局部重绘的轻量扫光。 -import 'package:flutter/material.dart'; - -const _sweepDuration = Duration(milliseconds: 1400); -const double _bandWidthRatio = 0.65; -const _highlight = Color(0x12FFFFFF); -const _clear = Color(0x00FFFFFF); - -/// 只用于等待真实图片的占位;减少动画或页面离屏时不运行扫光。 -class ImagePlaceholderShimmer extends StatefulWidget { - /// 在 [child] 上方绘制不影响布局与点击区域的扫光。 - const ImagePlaceholderShimmer({super.key, required this.child}); - - final Widget child; - - @override - State createState() => - _ImagePlaceholderShimmerState(); -} - -class _ImagePlaceholderShimmerState extends State - with SingleTickerProviderStateMixin { - late final AnimationController _controller = AnimationController( - vsync: this, - duration: _sweepDuration, - ); - bool _active = false; - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - _active = - TickerMode.valuesOf(context).enabled && - !MediaQuery.disableAnimationsOf(context); - if (_active) { - _controller.repeat(); - } else { - _controller.stop(); - } - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - if (!_active) return widget.child; - return RepaintBoundary( - child: ClipRect( - child: CustomPaint( - foregroundPainter: _SweepPainter(_controller), - child: widget.child, - ), - ), - ); - } -} - -class _SweepPainter extends CustomPainter { - _SweepPainter(this.progress) : super(repaint: progress); - - final Animation progress; - - @override - void paint(Canvas canvas, Size size) { - if (size.isEmpty) return; - final bandWidth = size.width * _bandWidthRatio; - final left = -bandWidth + (size.width + bandWidth) * progress.value; - final rect = Rect.fromLTWH(left, 0, bandWidth, size.height); - final paint = Paint() - ..shader = const LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [_clear, _highlight, _clear], - ).createShader(rect); - canvas.drawRect(rect, paint); - } - - @override - bool shouldRepaint(covariant _SweepPainter oldDelegate) => - oldDelegate.progress != progress; -} diff --git a/lib/core/widgets/remote_image.dart b/lib/core/widgets/remote_image.dart index 097883a..d307ec9 100644 --- a/lib/core/widgets/remote_image.dart +++ b/lib/core/widgets/remote_image.dart @@ -1,10 +1,10 @@ // Author: fruit.com.cn +// 职责:统一网络图片入口,补服务地址,并在加载中/失败时显示纯色占位块。 import 'package:flutter/material.dart'; import '../config/app_config.dart'; import '../theme/app_colors.dart'; -import 'image_placeholder_shimmer.dart'; const int _minImageDecodePixels = 96; const int _maxImageDecodePixels = 2048; @@ -32,8 +32,6 @@ class RemoteImage extends StatelessWidget { this.width, this.height, this.fit = BoxFit.cover, - this.showFallbackIcon = false, - this.fallbackIconKey, this.fallbackAspectRatio, this.fallbackColor = AppColors.raised, this.fallbackWidget, @@ -45,12 +43,10 @@ class RemoteImage extends StatelessWidget { final double? width; final double? height; final BoxFit fit; - final bool showFallbackIcon; - final Key? fallbackIconKey; final double? fallbackAspectRatio; final Color fallbackColor; - /// 自定义兜底:地址为空、加载中、加载失败时都显示它(优先于 [fallbackColor])。 + /// 自定义占位:地址为空、加载中、加载失败时都显示它(优先于 [fallbackColor])。 final Widget? fallbackWidget; final int? decodeWidth; final int? decodeHeight; @@ -58,7 +54,7 @@ class RemoteImage extends StatelessWidget { @override Widget build(BuildContext context) { final url = AppConfig.mediaUrl(path); - if (url.isEmpty) return _fallback(brand: showFallbackIcon); + if (url.isEmpty) return _fallback(); return LayoutBuilder( builder: (context, constraints) { final pixelRatio = MediaQuery.devicePixelRatioOf(context); @@ -100,51 +96,23 @@ class RemoteImage extends StatelessWidget { ?.clamp(_minImageDecodePixels, _maxImageDecodePixels) .toInt(), gaplessPlayback: true, - errorBuilder: (_, _, _) => _fallback(brand: showFallbackIcon), + errorBuilder: (_, _, _) => _fallback(), frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { if (wasSynchronouslyLoaded || frame != null) return child; - return _loadingFallback(); + return _fallback(); }, ); }, ); } - Widget _loadingFallback() { + Widget _fallback() { final custom = fallbackWidget; - if (custom != null) return custom; - return ImagePlaceholderShimmer(child: _fallback(brand: showFallbackIcon)); - } - - Widget _fallback({required bool brand}) { - final custom = fallbackWidget; - if (custom != null) return custom; - final mark = brand - ? LayoutBuilder( - builder: (context, constraints) { - final width = constraints.maxWidth.isFinite - ? constraints.maxWidth - : 120.0; - return Center( - child: Image.asset( - 'assets/icon/splash_logo.png', - key: fallbackIconKey, - width: width * 0.72, - fit: BoxFit.contain, - ), - ); - }, - ) - : null; - final fallback = Container( - width: width, - height: height, - color: brand ? AppColors.mediaBlack : fallbackColor, - alignment: Alignment.center, - child: mark, - ); + final content = custom != null + ? SizedBox(width: width, height: height, child: custom) + : Container(width: width, height: height, color: fallbackColor); final ratio = fallbackAspectRatio; - if (ratio == null) return fallback; - return AspectRatio(aspectRatio: ratio, child: fallback); + if (ratio == null) return content; + return AspectRatio(aspectRatio: ratio, child: content); } } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 8440a85..4633e72 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -83,6 +83,12 @@ "count": {"type": "String"} } }, + "likesLabel": "{count} likes", + "@likesLabel": { + "placeholders": { + "count": {"type": "String"} + } + }, "topCharts": "Hot Ranking", "newReleases": "New Releases", "newDramas": "New", @@ -359,7 +365,7 @@ "categoriesError": "Categories could not be loaded.", "detailError": "The episode list could not be loaded.", "noMediaNotice": "This episode has no playable media yet.", - "noStories": "No stories available yet.", + "noStories": "No stories available yet", "notices": "Notices", "noNotices": "No notices yet.", "noticesLoadFailed": "Notices could not be loaded.", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 21c9b2b..1f9f6d6 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -72,6 +72,7 @@ "authorNoteLabel": "Catatan penulis", "reserve": "Reservasi", "heatLabel": "{count} panas", + "likesLabel": "{count} suka", "topCharts": "Peringkat Populer", "newReleases": "Rilisan Baru", "newDramas": "Baru", @@ -338,7 +339,7 @@ "categoriesError": "Kategori gagal dimuat.", "detailError": "Daftar episode gagal dimuat.", "noMediaNotice": "Episode ini belum punya media yang bisa diputar.", - "noStories": "Belum ada cerita.", + "noStories": "Belum ada cerita", "notices": "Notifikasi", "noNotices": "Belum ada notifikasi.", "noticesLoadFailed": "Notifikasi tidak dapat dimuat.", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 01d871a..ed98fdc 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -442,6 +442,12 @@ abstract class AppLocalizations { /// **'{count} heat'** String heatLabel(String count); + /// No description provided for @likesLabel. + /// + /// In en, this message translates to: + /// **'{count} likes'** + String likesLabel(String count); + /// No description provided for @topCharts. /// /// In en, this message translates to: @@ -1393,7 +1399,7 @@ abstract class AppLocalizations { /// No description provided for @noStories. /// /// In en, this message translates to: - /// **'No stories available yet.'** + /// **'No stories available yet'** String get noStories; /// No description provided for @notices. diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 6ec70d6..58cdf14 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -191,6 +191,11 @@ class AppLocalizationsEn extends AppLocalizations { return '$count heat'; } + @override + String likesLabel(String count) { + return '$count likes'; + } + @override String get topCharts => 'Hot Ranking'; @@ -709,7 +714,7 @@ class AppLocalizationsEn extends AppLocalizations { String get noMediaNotice => 'This episode has no playable media yet.'; @override - String get noStories => 'No stories available yet.'; + String get noStories => 'No stories available yet'; @override String get notices => 'Notices'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index d09f864..d77c7ff 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -191,6 +191,11 @@ class AppLocalizationsId extends AppLocalizations { return '$count panas'; } + @override + String likesLabel(String count) { + return '$count suka'; + } + @override String get topCharts => 'Peringkat Populer'; @@ -712,7 +717,7 @@ class AppLocalizationsId extends AppLocalizations { 'Episode ini belum punya media yang bisa diputar.'; @override - String get noStories => 'Belum ada cerita.'; + String get noStories => 'Belum ada cerita'; @override String get notices => 'Notifikasi'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index bd48256..12b9a6c 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -189,6 +189,11 @@ class AppLocalizationsZh extends AppLocalizations { return '$count热度'; } + @override + String likesLabel(String count) { + return '$count次点赞'; + } + @override String get topCharts => '热播榜'; @@ -702,7 +707,7 @@ class AppLocalizationsZh extends AppLocalizations { String get noMediaNotice => '该剧集还没有可播放的片源。'; @override - String get noStories => '暂无剧目。'; + String get noStories => '暂无剧目'; @override String get notices => '通知'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 982c85f..09377b6 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -72,6 +72,7 @@ "authorNoteLabel": "作者声明", "reserve": "预约", "heatLabel": "{count}热度", + "likesLabel": "{count}次点赞", "topCharts": "热播榜", "newReleases": "新剧", "newDramas": "新剧", @@ -300,7 +301,7 @@ "categoriesError": "分类加载失败。", "detailError": "剧集信息加载失败。", "noMediaNotice": "该剧集还没有可播放的片源。", - "noStories": "暂无剧目。", + "noStories": "暂无剧目", "notices": "通知", "noNotices": "暂无通知。", "noticesLoadFailed": "通知加载失败,请重试。", diff --git a/lib/pages/discover/discover_view.dart b/lib/pages/discover/discover_view.dart index fff3617..a849f09 100644 --- a/lib/pages/discover/discover_view.dart +++ b/lib/pages/discover/discover_view.dart @@ -297,6 +297,7 @@ class _DiscoverPageState extends State { icon: Icons.movie_filter_outlined, title: l10n.noStories, subtitle: l10n.emptyHint, + stretchContent: true, ), ), ) diff --git a/lib/pages/discover/widgets/feed_banner_carousel.dart b/lib/pages/discover/widgets/feed_banner_carousel.dart index 839071c..a5ae199 100644 --- a/lib/pages/discover/widgets/feed_banner_carousel.dart +++ b/lib/pages/discover/widgets/feed_banner_carousel.dart @@ -176,7 +176,7 @@ class _FeedBannerCarouselState extends State { child: Stack( fit: StackFit.expand, children: [ - RemoteImage(banner.imageUrl, showFallbackIcon: true), + RemoteImage(banner.imageUrl), DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient( diff --git a/lib/pages/discover/widgets/search_result_card.dart b/lib/pages/discover/widgets/search_result_card.dart index bf6d0e1..634f2b4 100644 --- a/lib/pages/discover/widgets/search_result_card.dart +++ b/lib/pages/discover/widgets/search_result_card.dart @@ -37,7 +37,6 @@ class SearchResultCard extends StatelessWidget { RemoteImage( drama.posterUrl, fit: BoxFit.cover, - showFallbackIcon: true, fallbackColor: AppColors.contentSurface, ), const Positioned( @@ -60,7 +59,9 @@ class SearchResultCard extends StatelessWidget { ), const SizedBox(height: AppDimens.searchResultTitleGap), Text( - drama.localizedTitle(Localizations.localeOf(context).languageCode), + drama.localizedTitle( + Localizations.localeOf(context).languageCode, + ), key: Key('search-result-title-${drama.id}'), maxLines: 2, overflow: TextOverflow.ellipsis, @@ -93,7 +94,9 @@ class SearchResultCard extends StatelessWidget { } List _tags(BuildContext context) { - final localized = drama.localizedTypeLabels(Localizations.localeOf(context).languageCode); + 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] diff --git a/lib/pages/drama_detail/widgets/drama_detail_header.dart b/lib/pages/drama_detail/widgets/drama_detail_header.dart index 9b08c09..7c2dc34 100644 --- a/lib/pages/drama_detail/widgets/drama_detail_header.dart +++ b/lib/pages/drama_detail/widgets/drama_detail_header.dart @@ -126,7 +126,6 @@ class DramaDetailHeader extends StatelessWidget { child: RemoteImage( drama.posterUrl, fit: BoxFit.cover, - showFallbackIcon: true, fallbackAspectRatio: 3 / 4, ), ), diff --git a/lib/pages/favorites/favorites_page.dart b/lib/pages/favorites/favorites_page.dart index c4559e1..d37b9c3 100644 --- a/lib/pages/favorites/favorites_page.dart +++ b/lib/pages/favorites/favorites_page.dart @@ -38,7 +38,6 @@ const double _synopsisFontSize = 12; const double _synopsisLineHeight = 1.15; const double _synopsisGap = 4; const int _maxTags = 2; -const double _emptyListHeight = 360; const double _loadMoreThreshold = 240; final RegExp _tagSplitter = RegExp(r'[,/、·]'); @@ -124,37 +123,29 @@ class _FavoritesBody extends StatelessWidget { ); } if (favorites.error != null && favorites.cards.isEmpty) { - return _refreshable( + return _refreshableState( favorites, - children: [ - SizedBox( - height: _emptyListHeight, - child: EmptyState( - key: const Key('favorites-error'), - icon: Icons.cloud_off_rounded, - title: errorText(l10n, favorites.error, l10n.favoritesError), - actionLabel: l10n.retry, - actionKey: const Key('favorites-retry'), - onAction: () => unawaited(favorites.load()), - ), - ), - ], + child: EmptyState( + key: const Key('favorites-error'), + icon: Icons.cloud_off_rounded, + title: errorText(l10n, favorites.error, l10n.favoritesError), + actionLabel: l10n.retry, + actionKey: const Key('favorites-retry'), + onAction: () => unawaited(favorites.load()), + stretchContent: true, + ), ); } if (favorites.cards.isEmpty) { - return _refreshable( + return _refreshableState( favorites, - children: [ - SizedBox( - height: _emptyListHeight, - child: EmptyState( - key: const Key('favorites-empty'), - icon: Icons.star_border_rounded, - title: l10n.noFavorites, - subtitle: l10n.emptyHint, - ), - ), - ], + child: EmptyState( + key: const Key('favorites-empty'), + icon: Icons.star_border_rounded, + title: l10n.noFavorites, + subtitle: l10n.emptyHint, + stretchContent: true, + ), ); } return _refreshable( @@ -205,6 +196,20 @@ class _FavoritesBody extends StatelessWidget { ); } + Widget _refreshableState( + FavoriteController favorites, { + required Widget child, + }) { + return AppPullToRefresh( + onRefresh: favorites.load, + child: CustomScrollView( + key: const Key('favorites-empty-scroll'), + physics: AppPullToRefresh.physics, + slivers: [SliverFillRemaining(hasScrollBody: false, child: child)], + ), + ); + } + bool _onScroll(ScrollNotification notification) { if (notification.depth != 0) return false; final reachedBottom = diff --git a/lib/pages/filter/widgets/filter_result_card.dart b/lib/pages/filter/widgets/filter_result_card.dart index 0cdc2df..6d3cba0 100644 --- a/lib/pages/filter/widgets/filter_result_card.dart +++ b/lib/pages/filter/widgets/filter_result_card.dart @@ -53,7 +53,6 @@ class FilterResultCard extends StatelessWidget { RemoteImage( drama.posterUrl, fit: BoxFit.cover, - showFallbackIcon: true, fallbackAspectRatio: 3 / 4, ), if (drama.isHot) diff --git a/lib/pages/following/following_grid_card.dart b/lib/pages/following/following_grid_card.dart index ac360d8..639a794 100644 --- a/lib/pages/following/following_grid_card.dart +++ b/lib/pages/following/following_grid_card.dart @@ -64,7 +64,6 @@ class FollowingGridCard extends StatelessWidget { child: RemoteImage( posterUrl, fit: BoxFit.cover, - showFallbackIcon: true, fallbackColor: AppColors.contentSurface, ), ), diff --git a/lib/pages/following/following_history_card.dart b/lib/pages/following/following_history_card.dart index d47bbdf..43ca618 100644 --- a/lib/pages/following/following_history_card.dart +++ b/lib/pages/following/following_history_card.dart @@ -75,7 +75,6 @@ class FollowingHistoryCard extends StatelessWidget { child: RemoteImage( posterUrl, fit: BoxFit.cover, - showFallbackIcon: true, fallbackColor: AppColors.contentSurface, ), ), diff --git a/lib/pages/following/widgets/continue_watching_banner.dart b/lib/pages/following/widgets/continue_watching_banner.dart index 2097ad4..568d7a6 100644 --- a/lib/pages/following/widgets/continue_watching_banner.dart +++ b/lib/pages/following/widgets/continue_watching_banner.dart @@ -40,7 +40,6 @@ class ContinueWatchingBanner extends StatelessWidget { RemoteImage( record.displayLandscapeCover, fit: BoxFit.cover, - showFallbackIcon: true, fallbackColor: AppColors.contentSurface, ), const DecoratedBox( diff --git a/lib/pages/home/widgets/home_drama_grid.dart b/lib/pages/home/widgets/home_drama_grid.dart index b1f0980..33b688e 100644 --- a/lib/pages/home/widgets/home_drama_grid.dart +++ b/lib/pages/home/widgets/home_drama_grid.dart @@ -122,7 +122,6 @@ class HomeDramaCard extends StatelessWidget { drama.posterUrl, width: double.infinity, fit: BoxFit.fitWidth, - showFallbackIcon: true, fallbackAspectRatio: _coverAspectRatio, ), Positioned(top: 8, left: 8, child: _tagBadge()), @@ -226,7 +225,9 @@ class HomeDramaCard extends StatelessWidget { } Widget _genre(BuildContext context) { - final labels = drama.localizedTypeLabels(Localizations.localeOf(context).languageCode); + final labels = drama.localizedTypeLabels( + Localizations.localeOf(context).languageCode, + ); final genre = labels.isEmpty ? (drama.genre.isEmpty ? drama.category : drama.genre) : labels.join(' / '); diff --git a/lib/pages/home/widgets/home_feed_card.dart b/lib/pages/home/widgets/home_feed_card.dart index e8aeaa5..f957715 100644 --- a/lib/pages/home/widgets/home_feed_card.dart +++ b/lib/pages/home/widgets/home_feed_card.dart @@ -12,11 +12,7 @@ import '../../../core/widgets/remote_image.dart'; final RegExp _tagSplitter = RegExp(r'[,/、·]'); class HomeFeedCard extends StatelessWidget { - const HomeFeedCard({ - super.key, - required this.drama, - required this.onTap, - }); + const HomeFeedCard({super.key, required this.drama, required this.onTap}); final Drama drama; final VoidCallback onTap; @@ -44,7 +40,9 @@ class HomeFeedCard extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.start, children: [ Text( - drama.localizedTitle(Localizations.localeOf(context).languageCode), + drama.localizedTitle( + Localizations.localeOf(context).languageCode, + ), key: Key('home-feed-title-${drama.id}'), maxLines: 2, overflow: TextOverflow.ellipsis, @@ -69,7 +67,9 @@ class HomeFeedCard extends StatelessWidget { } Widget _cover(BuildContext context) { - final label = drama.localizedTag(Localizations.localeOf(context).languageCode); + final label = drama.localizedTag( + Localizations.localeOf(context).languageCode, + ); return ClipRRect( key: Key('home-feed-cover-clip-${drama.id}'), borderRadius: BorderRadius.circular(AppDimens.cardCoverRadius), @@ -82,7 +82,6 @@ class HomeFeedCard extends StatelessWidget { drama.posterUrl, key: Key('home-feed-cover-${drama.id}'), fit: BoxFit.cover, - showFallbackIcon: true, fallbackColor: AppColors.contentSurface, ), if (label.isNotEmpty) @@ -133,7 +132,9 @@ class HomeFeedCard extends StatelessWidget { } List _cardTags(BuildContext context) { - final localized = drama.localizedTypeLabels(Localizations.localeOf(context).languageCode); + 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] diff --git a/lib/pages/home/widgets/home_feed_grid.dart b/lib/pages/home/widgets/home_feed_grid.dart index 3afbc7f..33c9640 100644 --- a/lib/pages/home/widgets/home_feed_grid.dart +++ b/lib/pages/home/widgets/home_feed_grid.dart @@ -22,6 +22,7 @@ import '../../../l10n/app_localizations.dart'; import 'home_category_dramas.dart'; import 'home_feed_card.dart'; import 'home_grid_header.dart'; +import 'home_ranking_dramas.dart'; const double _loadMoreThreshold = 240; @@ -175,6 +176,12 @@ class _HomeFeedGridState extends State { required CatalogController catalog, }) { final controller = context.read(); + if (category.isRanking) { + return HomeRankingDramas( + categoryId: category.categoryId, + onOpenDrama: widget.onOpenDrama, + ); + } if (controller.dramaPageLoader != null && category.categoryId.isNotEmpty) { return HomeCategoryDramas( lockedCategoryId: category.categoryId, @@ -320,6 +327,7 @@ class _HomeFeedGridState extends State { categoryName: category.name, categoryId: category.id, tab: HomePrimaryTab.categories, + layoutType: category.layoutType, ), ]; } diff --git a/lib/pages/home/widgets/home_grid_header.dart b/lib/pages/home/widgets/home_grid_header.dart index 4110c6d..ec1892a 100644 --- a/lib/pages/home/widgets/home_grid_header.dart +++ b/lib/pages/home/widgets/home_grid_header.dart @@ -23,12 +23,16 @@ class HomeGridCategoryShortcut { required this.categoryName, required this.categoryId, required this.tab, + this.layoutType = 'GRID', }); final String label; final String categoryName; final String categoryId; final HomePrimaryTab tab; + final String layoutType; + + bool get isRanking => layoutType == 'RANKING'; } class HomeGridCallbacks { diff --git a/lib/pages/home/widgets/home_ranking_dramas.dart b/lib/pages/home/widgets/home_ranking_dramas.dart new file mode 100644 index 0000000..443dc6c --- /dev/null +++ b/lib/pages/home/widgets/home_ranking_dramas.dart @@ -0,0 +1,173 @@ +// Author: fruit.com.cn + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../../core/data/models/category_ranking.dart'; +import '../../../core/data/models/drama.dart'; +import '../../../core/data/services/category_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/drama_grid_skeleton.dart'; +import '../../../core/widgets/empty_state.dart'; +import '../../../l10n/app_localizations.dart'; +import '../../ranking/widgets/ranking_drama_row.dart'; + +/// 榜单页顶部暗红光晕,向下淡入纯黑(与剧场榜单页一致)。 +const BoxDecoration _rankingBackdrop = BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + AppColors.followingGlow, + AppColors.followingGlowMid, + AppColors.appBackground, + ], + stops: [0, 0.14, 0.3], + ), +); + +/// 推荐榜频道:服务端排名快照,统一行布局,名次角标压在封面左上角。 +class HomeRankingDramas extends StatefulWidget { + const HomeRankingDramas({ + super.key, + required this.categoryId, + required this.onOpenDrama, + this.topPadding = AppDimens.spaceLg, + }); + + final String categoryId; + final ValueChanged onOpenDrama; + final double topPadding; + + @override + State createState() => _HomeRankingDramasState(); +} + +class _HomeRankingDramasState extends State { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _ensureLoaded()); + } + + @override + void didUpdateWidget(HomeRankingDramas oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.categoryId != widget.categoryId) _ensureLoaded(); + } + + void _ensureLoaded() { + if (!mounted) return; + final controller = context.read(); + final slot = controller.rankingSlotOf(widget.categoryId); + if (slot.loading || slot.ranking != null) return; + unawaited(controller.openRanking(widget.categoryId)); + } + + @override + Widget build(BuildContext context) { + final controller = context.watch(); + final slot = controller.rankingSlotOf(widget.categoryId); + final entries = slot.ranking?.entries ?? const []; + if (slot.loading && entries.isEmpty) { + return const DecoratedBox( + key: Key('home-ranking-surface'), + decoration: _rankingBackdrop, + child: DramaGridSkeleton( + key: Key('home-ranking-skeleton'), + columns: 3, + horizontalPadding: AppDimens.feedPageHorizontal, + crossAxisSpacing: AppDimens.homeGridGap, + mainAxisSpacing: AppDimens.homeGridRowGap, + ), + ); + } + return DecoratedBox( + key: const Key('home-ranking-surface'), + decoration: _rankingBackdrop, + child: Material( + type: MaterialType.transparency, + child: AppPullToRefresh( + key: const Key('home-ranking-refresh'), + onRefresh: () => controller.openRanking(widget.categoryId), + child: _content(context, entries, slot.error), + ), + ), + ); + } + + Widget _content( + BuildContext context, + List entries, + Object? error, + ) { + final l10n = AppLocalizations.of(context)!; + if (entries.isEmpty) { + return CustomScrollView( + key: PageStorageKey('home-ranking-scroll-${widget.categoryId}'), + physics: AppPullToRefresh.physics, + slivers: [ + SliverFillRemaining( + hasScrollBody: false, + child: EmptyState( + key: Key( + error == null ? 'home-ranking-empty' : 'home-ranking-error', + ), + icon: error == null + ? Icons.leaderboard_outlined + : Icons.cloud_off_rounded, + title: error == null ? l10n.emptyContent : l10n.catalogError, + actionLabel: error == null ? null : l10n.retry, + onAction: error == null + ? null + : () => unawaited( + context.read().openRanking( + widget.categoryId, + ), + ), + stretchContent: true, + ), + ), + ], + ); + } + return CustomScrollView( + key: PageStorageKey('home-ranking-scroll-${widget.categoryId}'), + physics: AppPullToRefresh.physics, + slivers: [ + SliverPadding( + padding: EdgeInsets.fromLTRB( + AppDimens.feedPageHorizontal, + widget.topPadding, + AppDimens.feedPageHorizontal, + AppDimens.spaceXl, + ), + sliver: SliverList.separated( + itemCount: entries.length, + itemBuilder: (context, index) => _row(entries[index]), + separatorBuilder: (_, _) => const Divider( + height: AppDimens.mineGridDividerWidth, + thickness: AppDimens.mineGridDividerWidth, + color: AppColors.border, + ), + ), + ), + ], + ); + } + + Widget _row(CategoryRankingEntry entry) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: AppDimens.spaceSm), + child: RankingDramaRow( + rank: entry.rank, + drama: entry.drama, + onTap: () => widget.onOpenDrama(entry.drama), + ), + ); + } +} diff --git a/lib/pages/mine/widgets/mine_continue_watching.dart b/lib/pages/mine/widgets/mine_continue_watching.dart index b6c2c06..33a7f67 100644 --- a/lib/pages/mine/widgets/mine_continue_watching.dart +++ b/lib/pages/mine/widgets/mine_continue_watching.dart @@ -128,7 +128,6 @@ class MineContinueWatching extends StatelessWidget { child: RemoteImage( record.dramaCover ?? '', fit: BoxFit.cover, - showFallbackIcon: true, fallbackColor: AppColors.contentSurface, ), ), diff --git a/lib/pages/new_releases/new_releases_page.dart b/lib/pages/new_releases/new_releases_page.dart index 85c066e..fb0a648 100644 --- a/lib/pages/new_releases/new_releases_page.dart +++ b/lib/pages/new_releases/new_releases_page.dart @@ -276,7 +276,6 @@ class _NewReleasesPageState extends State { RemoteImage( drama.posterUrl, fit: BoxFit.cover, - showFallbackIcon: true, fallbackAspectRatio: 16 / 9, ), const DecoratedBox( @@ -448,7 +447,6 @@ class _NewReleasesPageState extends State { RemoteImage( drama.posterUrl, fit: BoxFit.cover, - showFallbackIcon: true, fallbackAspectRatio: 3 / 4, ), Positioned( diff --git a/lib/pages/player/widgets/drama_info_sheet.dart b/lib/pages/player/widgets/drama_info_sheet.dart index 18a9b03..03caee8 100644 --- a/lib/pages/player/widgets/drama_info_sheet.dart +++ b/lib/pages/player/widgets/drama_info_sheet.dart @@ -255,11 +255,7 @@ class _DramaInfoSheetState extends State { width: _posterWidth, child: AspectRatio( aspectRatio: 3 / 4, - child: RemoteImage( - drama.posterUrl, - fit: BoxFit.cover, - showFallbackIcon: true, - ), + child: RemoteImage(drama.posterUrl, fit: BoxFit.cover), ), ), ), @@ -466,11 +462,7 @@ class _DramaInfoSheetState extends State { width: _relatedWidth, child: AspectRatio( aspectRatio: 3 / 4, - child: RemoteImage( - drama.posterUrl, - fit: BoxFit.cover, - showFallbackIcon: true, - ), + child: RemoteImage(drama.posterUrl, fit: BoxFit.cover), ), ), ), diff --git a/lib/pages/ranking/ranking_page.dart b/lib/pages/ranking/ranking_page.dart index 898cf2a..231c788 100644 --- a/lib/pages/ranking/ranking_page.dart +++ b/lib/pages/ranking/ranking_page.dart @@ -7,26 +7,20 @@ import 'package:provider/provider.dart'; import '../../core/data/models/drama.dart'; import '../../core/data/services/catalog_controller.dart'; -import '../../core/formatting/count_format.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 '../../core/widgets/paged_local_list.dart'; import '../../core/widgets/plain_page_scaffold.dart'; -import '../../core/widgets/remote_image.dart'; import '../../l10n/app_localizations.dart'; import '../player/open_player.dart'; +import 'widgets/ranking_drama_row.dart'; const int _topCount = 50; const double _loadMoreThreshold = 200; -const double _championLift = 12; -const double _podiumGap = 8; -const double _podiumHorizontal = 8; -const double _championScale = 1.18; -const double _coverAspect = 146 / 110; -/// 剧场榜单页:前三名领奖台,第 4 名起为单列排行行。 +/// 剧场榜单页:统一行布局,名次角标压在封面左上角,右侧字段无数据不展示。 class RankingPage extends StatefulWidget { const RankingPage({super.key, this.embedded = false}); @@ -101,8 +95,6 @@ class _RankingPageState extends State { final l10n = AppLocalizations.of(context)!; final catalog = context.watch(); final top = _pager!.visibleItems; - final podium = top.take(3).toList(growable: false); - final rest = top.skip(3).toList(growable: false); final content = NotificationListener( onNotification: _onScroll, child: AppPullToRefresh( @@ -118,38 +110,31 @@ class _RankingPageState extends State { : [ if (widget.embedded) _intro(l10n), const SizedBox(height: AppDimens.spaceMd), - if (podium.isNotEmpty) + for (var index = 0; index < top.length; index++) ...[ Padding( padding: const EdgeInsets.symmetric( - horizontal: _podiumHorizontal, + horizontal: AppDimens.feedPageHorizontal, + vertical: AppDimens.spaceSm, + ), + child: RankingDramaRow( + rank: index + 1, + drama: top[index], + onTap: () => unawaited( + playDramaFromCatalog(context, top[index]), + ), ), - child: _Podium(podium: podium, l10n: l10n), ), - if (rest.isNotEmpty) ...[ - const SizedBox(height: AppDimens.spaceXl), - for (var index = 0; index < rest.length; index++) ...[ - Padding( - padding: const EdgeInsets.symmetric( + if (index != top.length - 1) + const Padding( + padding: EdgeInsets.symmetric( horizontal: AppDimens.feedPageHorizontal, ), - child: _RankingRow( - index: index + 3, - drama: rest[index], - l10n: l10n, + child: Divider( + height: AppDimens.mineGridDividerWidth, + thickness: AppDimens.mineGridDividerWidth, + color: AppColors.border, ), ), - if (index != rest.length - 1) - const Padding( - padding: EdgeInsets.symmetric( - horizontal: AppDimens.feedPageHorizontal, - ), - child: Divider( - height: AppDimens.mineGridDividerWidth, - thickness: AppDimens.mineGridDividerWidth, - color: AppColors.border, - ), - ), - ], ], PagedLoadFooter( loading: _pager!.loading, @@ -221,302 +206,9 @@ class _RankingPageState extends State { title: catalog.error == null ? l10n.noStories : l10n.catalogError, actionLabel: catalog.error == null ? null : l10n.retry, onAction: catalog.error == null ? null : () => unawaited(_refresh()), + stretchContent: true, ), ), ); } } - -class _Podium extends StatelessWidget { - const _Podium({required this.podium, required this.l10n}); - - final List podium; - final AppLocalizations l10n; - - @override - Widget build(BuildContext context) { - return LayoutBuilder( - builder: (context, constraints) { - final available = constraints.maxWidth - _podiumGap * 2; - final side = available / (2 + _championScale); - final champion = side * _championScale; - return Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - SizedBox( - width: side, - child: podium.length > 1 - ? _PodiumCard( - rank: 2, - drama: podium[1], - l10n: l10n, - coverWidth: side, - ) - : const SizedBox.shrink(), - ), - const SizedBox(width: _podiumGap), - SizedBox( - width: champion, - child: podium.isNotEmpty - ? Transform.translate( - offset: const Offset(0, -_championLift), - child: _PodiumCard( - rank: 1, - drama: podium.first, - l10n: l10n, - coverWidth: champion, - ), - ) - : const SizedBox.shrink(), - ), - const SizedBox(width: _podiumGap), - SizedBox( - width: side, - child: podium.length > 2 - ? _PodiumCard( - rank: 3, - drama: podium[2], - l10n: l10n, - coverWidth: side, - ) - : const SizedBox.shrink(), - ), - ], - ); - }, - ); - } -} - -class _PodiumCard extends StatelessWidget { - const _PodiumCard({ - required this.rank, - required this.drama, - required this.l10n, - required this.coverWidth, - }); - - final int rank; - final Drama drama; - final AppLocalizations l10n; - final double coverWidth; - - double get _coverHeight => coverWidth * _coverAspect; - - Color get _badgeColor => switch (rank) { - 1 => AppColors.homeCrownGold, - 2 => AppColors.rankSilver, - _ => AppColors.bronze, - }; - - Color get _badgeTextColor => - rank == 3 ? AppColors.textPrimary : AppColors.onGold; - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: () => unawaited(playDramaFromCatalog(context, drama)), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(AppDimens.rankingPodiumRadius), - child: SizedBox( - key: Key('ranking-cover-$rank'), - width: coverWidth, - height: _coverHeight, - child: Stack( - fit: StackFit.expand, - children: [ - RemoteImage( - drama.posterUrl, - fit: BoxFit.cover, - showFallbackIcon: true, - fallbackAspectRatio: 3 / 4, - ), - Positioned( - top: 4, - left: 4, - child: Container( - key: Key('ranking-top-$rank'), - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - decoration: BoxDecoration( - color: _badgeColor, - borderRadius: BorderRadius.circular(6), - ), - child: Text( - l10n.topLabel(rank), - maxLines: 1, - style: TextStyle( - color: _badgeTextColor, - fontSize: 11, - fontWeight: FontWeight.w800, - ), - ), - ), - ), - ], - ), - ), - ), - const SizedBox(height: 6), - SizedBox( - height: 34, - child: Text( - drama.title, - key: Key('ranking-title-$rank'), - maxLines: 2, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.center, - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: 13, - fontWeight: FontWeight.w600, - height: 1.25, - ), - ), - ), - if (drama.viewCount > 0) ...[ - const SizedBox(height: 2), - _heat(drama.viewCount, l10n), - ], - ], - ), - ); - } -} - -class _RankingRow extends StatelessWidget { - const _RankingRow({ - required this.index, - required this.drama, - required this.l10n, - }); - - final int index; - final Drama drama; - final AppLocalizations l10n; - - int get _rank => index + 1; - - String get _category { - final values = {}; - for (final value in [drama.genre, drama.category]) { - for (final part in value.split(RegExp(r'[,/、·]'))) { - final tag = part.trim(); - if (tag.isNotEmpty) values.add(tag); - } - } - return values.join(' · '); - } - - @override - Widget build(BuildContext context) { - return SizedBox( - height: AppDimens.rankingRowHeight, - child: Material( - color: AppColors.transparent, - child: InkWell( - key: Key('ranking-row-$index'), - onTap: () => unawaited(playDramaFromCatalog(context, drama)), - child: Row( - children: [ - SizedBox( - width: AppDimens.rankingNumberWidth, - child: Text( - '$_rank', - key: Key('ranking-top-$_rank'), - textAlign: TextAlign.center, - maxLines: 1, - style: TextStyle( - color: AppColors.textPrimary.withValues(alpha: 0.12), - fontSize: AppDimens.rankingNumberFont, - fontWeight: FontWeight.w800, - height: 1, - ), - ), - ), - ClipRRect( - borderRadius: BorderRadius.circular( - AppDimens.rankingCoverRadius, - ), - child: SizedBox( - key: Key('ranking-row-cover-$_rank'), - width: AppDimens.rankingCoverWidth, - height: AppDimens.rankingCoverHeight, - child: RemoteImage( - drama.posterUrl, - fit: BoxFit.cover, - showFallbackIcon: true, - fallbackAspectRatio: 3 / 4, - ), - ), - ), - const SizedBox(width: AppDimens.spaceMd), - Expanded( - child: SizedBox( - height: AppDimens.rankingCoverHeight, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - drama.title, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: 15, - fontWeight: FontWeight.w500, - ), - ), - if (_category.isNotEmpty) ...[ - const SizedBox(height: AppDimens.spaceXs), - Text( - _category, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.textTertiary, - fontSize: 11, - ), - ), - ], - const Spacer(), - if (drama.viewCount > 0) _heat(drama.viewCount, l10n), - ], - ), - ), - ), - ], - ), - ), - ), - ); - } -} - -Widget _heat(int count, AppLocalizations l10n) { - return Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon( - Icons.local_fire_department_rounded, - color: AppColors.textTertiary, - size: 11, - ), - const SizedBox(width: 4), - Flexible( - child: Text( - l10n.heatLabel(formatPlayCount(count)), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(color: AppColors.textTertiary, fontSize: 11), - ), - ), - ], - ); -} diff --git a/lib/pages/ranking/widgets/ranking_drama_row.dart b/lib/pages/ranking/widgets/ranking_drama_row.dart new file mode 100644 index 0000000..eeb4b79 --- /dev/null +++ b/lib/pages/ranking/widgets/ranking_drama_row.dart @@ -0,0 +1,229 @@ +// Author: fruit.com.cn +// 榜单统一行:名次角标压在封面左上角,右侧字段无数据不展示。 + +import 'package:flutter/material.dart'; + +import '../../../core/data/models/drama.dart'; +import '../../../core/formatting/count_format.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_dimens.dart'; +import '../../../core/widgets/remote_image.dart'; +import '../../../l10n/app_localizations.dart'; + +const double _badgeSize = 24; +const double _badgeCornerRadius = 10; +const double _badgeFontSize = 14; +const double _titleFontSize = 16; +const double _metaLineGap = 2; +const double _lineHeight = 1.25; +const int _synopsisMaxLines = 3; +const double _metaFontSize = 12; +const double _tagFontSize = 10; +const double _flameSize = 13; +const double _flameGap = 2; +const double _badgeMutedAlpha = 0.6; +const double _badgeTextHeight = 1; + +/// 榜单行:左侧 3:4 封面带名次角标,右侧标题/分类/简介/底行元信息。 +class RankingDramaRow extends StatelessWidget { + const RankingDramaRow({ + super.key, + required this.rank, + required this.drama, + required this.onTap, + }); + + final int rank; + final Drama drama; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return SizedBox( + height: AppDimens.rankingCardCoverHeight, + child: Material( + color: AppColors.transparent, + child: InkWell( + key: Key('ranking-row-${rank - 1}'), + onTap: onTap, + child: Row( + children: [ + _cover(), + const SizedBox(width: AppDimens.spaceMd), + Expanded(child: _info(l10n)), + ], + ), + ), + ), + ); + } + + String get _category { + final values = {}; + for (final value in [drama.genre, drama.category]) { + for (final part in value.split(RegExp(r'[,/、·]'))) { + final tag = part.trim(); + if (tag.isNotEmpty) values.add(tag); + } + } + return values.join(' · '); + } + + Color get _badgeColor => switch (rank) { + 1 => AppColors.brandRed, + 2 => AppColors.homeCrownGold, + 3 => AppColors.bronze, + _ => AppColors.ink.withValues(alpha: _badgeMutedAlpha), + }; + + Widget _badge() { + return Container( + key: Key('ranking-top-$rank'), + width: _badgeSize, + height: _badgeSize, + alignment: Alignment.center, + decoration: BoxDecoration( + color: _badgeColor, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(AppDimens.rankingCoverRadius), + bottomRight: Radius.circular(_badgeCornerRadius), + ), + ), + child: Text( + '$rank', + maxLines: 1, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: _badgeFontSize, + fontWeight: FontWeight.w700, + height: _badgeTextHeight, + ), + ), + ); + } + + Widget _cover() { + return ClipRRect( + borderRadius: BorderRadius.circular(AppDimens.rankingCoverRadius), + child: SizedBox( + key: Key('ranking-row-cover-$rank'), + width: AppDimens.rankingCardCoverWidth, + height: AppDimens.rankingCardCoverHeight, + child: Stack( + fit: StackFit.expand, + children: [ + RemoteImage( + drama.posterUrl, + fit: BoxFit.cover, + fallbackWidget: const ColoredBox(color: AppColors.raised), + ), + Positioned(top: 0, left: 0, child: _badge()), + ], + ), + ), + ); + } + + Widget _info(AppLocalizations l10n) { + final category = _category; + final synopsis = drama.synopsis.trim(); + return SizedBox( + height: AppDimens.rankingCardCoverHeight, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + drama.title, + key: Key('ranking-title-$rank'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: _titleFontSize, + fontWeight: FontWeight.w600, + height: _lineHeight, + ), + ), + if (category.isNotEmpty) _metaText(category), + if (synopsis.isNotEmpty) + _metaText(synopsis, maxLines: _synopsisMaxLines), + const SizedBox(height: _metaLineGap), + _bottomMeta(l10n), + ], + ), + ); + } + + Widget _metaText(String text, {int maxLines = 1}) { + return Padding( + padding: const EdgeInsets.only(top: _metaLineGap), + child: Text( + text, + maxLines: maxLines, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textTertiary, + fontSize: _metaFontSize, + height: _lineHeight, + ), + ), + ); + } + + Widget _bottomMeta(AppLocalizations l10n) { + final tag = drama.tag.trim(); + final metas = [ + if (drama.viewCount > 0) l10n.heatLabel(formatPlayCount(drama.viewCount)), + if (drama.likeCount != null) + l10n.likesLabel(formatInteractionCount(drama.likeCount!)), + ]; + if (tag.isEmpty && metas.isEmpty) return const SizedBox.shrink(); + return Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: AppDimens.spaceSm, + runSpacing: _metaLineGap, + children: [ + if (tag.isNotEmpty) _tagText(tag), + if (metas.isNotEmpty) + Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.local_fire_department_rounded, + color: AppColors.actionRed, + size: _flameSize, + ), + const SizedBox(width: _flameGap), + Flexible( + child: Text( + metas.join(' · '), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textTertiary, + fontSize: _metaFontSize, + height: _lineHeight, + ), + ), + ), + ], + ), + ], + ); + } + + Widget _tagText(String tag) { + return Text( + tag, + key: Key('ranking-tag-$rank'), + maxLines: 1, + style: TextStyle( + color: drama.tagRibbonColor(AppColors.brandRed), + fontSize: _tagFontSize, + fontWeight: FontWeight.w600, + height: _lineHeight, + ), + ); + } +} diff --git a/lib/pages/recommend/recommend_discover_page.dart b/lib/pages/recommend/recommend_discover_page.dart index ce9a17f..948ccb9 100644 --- a/lib/pages/recommend/recommend_discover_page.dart +++ b/lib/pages/recommend/recommend_discover_page.dart @@ -10,11 +10,13 @@ import '../../core/data/models/home_feed.dart'; import '../../core/data/remote/catalog_api.dart'; import '../../core/data/services/category_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 '../../core/widgets/paged_list_footer.dart'; import '../../l10n/app_localizations.dart'; import '../player/open_player.dart'; +import '../home/widgets/home_ranking_dramas.dart'; import 'widgets/home_hero_carousel.dart'; import 'widgets/home_recommend_content.dart'; import 'widgets/recommend_channel_nav.dart'; @@ -100,6 +102,21 @@ class _RecommendDiscoverPageState extends State if (_requestedCategories.contains(id)) return; _requestedCategories.add(id); final categories = context.read(); + final category = categories.categories + .where((item) => item.id == id) + .firstOrNull; + if (category?.isRanking ?? false) { + final slot = categories.rankingSlotOf(id); + if (categories.rankingLoader == null || + slot.ranking != null || + slot.loading) { + return; + } + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) unawaited(categories.openRanking(id)); + }); + return; + } if (categories.dramaPageLoader == null) return; final slot = categories.slotOf(id); if (slot.dramas.isNotEmpty || slot.loading) return; @@ -171,6 +188,16 @@ class _RecommendDiscoverPageState extends State onPageChanged: (i) => _onPageChanged(i, orderedIds), itemBuilder: (context, index) { final category = categoryList[index]; + if (category.isRanking) { + return HomeRankingDramas( + key: PageStorageKey( + 'recommend-ranking-${category.id}', + ), + categoryId: category.id, + topPadding: mediaTop + RecommendChannelNav.height, + onOpenDrama: _openDrama, + ); + } return _ChannelFeed( key: PageStorageKey( 'recommend-channel-${category.id}', @@ -318,21 +345,14 @@ class _ChannelFeedState extends State<_ChannelFeed> sliverList.add( SliverFillRemaining( hasScrollBody: false, - child: Padding( - padding: EdgeInsets.only( - top: showCarousel - ? 0 - : widget.mediaTop + RecommendChannelNav.height, - ), - child: Center(child: _placeholder(slot.loading)), - ), + child: Center(child: _placeholder(slot.loading)), ), ); } else if (widget.isFeatured) { // 无轮播图时内容需避开顶部悬浮导航。 final contentTop = showCarousel ? 24.0 - : widget.mediaTop + RecommendChannelNav.height + 16; + : widget.mediaTop + RecommendChannelNav.height + AppDimens.spaceSm; if (newArrivals.isNotEmpty) { sliverList.add(SliverToBoxAdapter(child: SizedBox(height: contentTop))); sliverList.add( @@ -358,7 +378,9 @@ class _ChannelFeedState extends State<_ChannelFeed> dramas, topPadding: showCarousel ? 20 - : widget.mediaTop + RecommendChannelNav.height + 16, + : widget.mediaTop + + RecommendChannelNav.height + + AppDimens.spaceSm, crossAxisCount: 3, ), ); @@ -411,6 +433,8 @@ class _ChannelFeedState extends State<_ChannelFeed> key: const Key('recommend-category-empty'), icon: Icons.movie_outlined, title: l10n.noStories, + compact: true, + stretchContent: true, ); } diff --git a/lib/pages/recommend/recommend_page.dart b/lib/pages/recommend/recommend_page.dart index e7f136e..26e9a14 100644 --- a/lib/pages/recommend/recommend_page.dart +++ b/lib/pages/recommend/recommend_page.dart @@ -348,6 +348,7 @@ class _RecommendPageState extends State key: const Key('recommend-feed-empty'), icon: Icons.movie_outlined, title: l10n.noStories, + stretchContent: true, ), ); } diff --git a/lib/pages/recommend/widgets/home_hero_carousel.dart b/lib/pages/recommend/widgets/home_hero_carousel.dart index 37e5d87..77b9d0f 100644 --- a/lib/pages/recommend/widgets/home_hero_carousel.dart +++ b/lib/pages/recommend/widgets/home_hero_carousel.dart @@ -187,7 +187,6 @@ class _HomeHeroCarouselState extends State { RemoteImage( banner.imageUrl, fit: BoxFit.cover, - showFallbackIcon: true, fallbackColor: AppColors.contentSurface, ), const DecoratedBox( @@ -210,10 +209,7 @@ class _HomeHeroCarouselState extends State { left: 28, right: 28, bottom: _copyBottomInset, - child: _HeroCopy( - key: Key('home-hero-copy-$index'), - drama: drama, - ), + child: _HeroCopy(key: Key('home-hero-copy-$index'), drama: drama), ), if (drama != null) Positioned.fill( diff --git a/lib/pages/recommend/widgets/home_recommend_content.dart b/lib/pages/recommend/widgets/home_recommend_content.dart index 181c416..8e4cd88 100644 --- a/lib/pages/recommend/widgets/home_recommend_content.dart +++ b/lib/pages/recommend/widgets/home_recommend_content.dart @@ -97,7 +97,6 @@ class HomePosterCard extends StatelessWidget { RemoteImage( drama.posterUrl, fit: BoxFit.cover, - showFallbackIcon: true, fallbackColor: AppColors.contentSurface, ), if (tagLabel.isNotEmpty) @@ -189,7 +188,6 @@ class HomeEditorPickBanner extends StatelessWidget { RemoteImage( drama.posterUrl, fit: BoxFit.cover, - showFallbackIcon: true, fallbackColor: AppColors.contentSurface, ), const DecoratedBox( diff --git a/lib/pages/recommend/widgets/playlist_detail_page.dart b/lib/pages/recommend/widgets/playlist_detail_page.dart index d847754..17c62e0 100644 --- a/lib/pages/recommend/widgets/playlist_detail_page.dart +++ b/lib/pages/recommend/widgets/playlist_detail_page.dart @@ -310,7 +310,6 @@ class _PlaylistCollage extends StatelessWidget { child: RemoteImage( drama.posterUrl, fit: BoxFit.cover, - showFallbackIcon: true, fallbackColor: _filmBody, ), ); diff --git a/lib/pages/recommend/widgets/recommend_section_card.dart b/lib/pages/recommend/widgets/recommend_section_card.dart index eaf53c1..9adb90a 100644 --- a/lib/pages/recommend/widgets/recommend_section_card.dart +++ b/lib/pages/recommend/widgets/recommend_section_card.dart @@ -25,7 +25,9 @@ class RecommendSectionCard extends StatelessWidget { @override Widget build(BuildContext context) { - final tagLabel = drama.localizedTag(Localizations.localeOf(context).languageCode); + final tagLabel = drama.localizedTag( + Localizations.localeOf(context).languageCode, + ); return SizedBox( width: AppDimens.recommendCardWidth, child: InkWell( @@ -47,7 +49,6 @@ class RecommendSectionCard extends StatelessWidget { RemoteImage( drama.posterUrl, fit: BoxFit.cover, - showFallbackIcon: true, fallbackColor: AppColors.contentSurface, ), if (tagLabel.isNotEmpty) @@ -80,7 +81,9 @@ class RecommendSectionCard extends StatelessWidget { ), const SizedBox(height: AppDimens.cardCoverTitleGap), Text( - drama.localizedTitle(Localizations.localeOf(context).languageCode), + drama.localizedTitle( + Localizations.localeOf(context).languageCode, + ), key: Key('recommend-title-$sectionKey-${drama.id}'), maxLines: 2, overflow: TextOverflow.ellipsis, @@ -116,7 +119,9 @@ class RecommendSectionCard extends StatelessWidget { } List _cardTags(BuildContext context) { - final localized = drama.localizedTypeLabels(Localizations.localeOf(context).languageCode); + 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] diff --git a/lib/pages/watch_records/watch_records_page.dart b/lib/pages/watch_records/watch_records_page.dart index d8e2e7d..8b31ad2 100644 --- a/lib/pages/watch_records/watch_records_page.dart +++ b/lib/pages/watch_records/watch_records_page.dart @@ -26,7 +26,6 @@ import 'widgets/watch_record_card.dart'; const double _pageHorizontal = 14; const double _statsFontSize = 12; -const double _emptyListHeight = 360; const double _loadMoreThreshold = 240; /// 独立观看记录页。 @@ -113,31 +112,22 @@ class _WatchRecordsBodyState extends State<_WatchRecordsBody> { ); } if (controller.error != null && controller.records.isEmpty) { - return _refreshable( - children: [ - SizedBox( - height: _emptyListHeight, - child: _FailureState( - message: errorText(l10n, controller.error, l10n.progressError), - onRetry: controller.load, - ), - ), - ], + return _refreshableState( + child: _FailureState( + message: errorText(l10n, controller.error, l10n.progressError), + onRetry: controller.load, + ), ); } if (controller.records.isEmpty) { - return _refreshable( - children: [ - SizedBox( - height: _emptyListHeight, - child: EmptyState( - key: const Key('watch-records-empty'), - icon: Icons.history_rounded, - title: l10n.noWatchRecords, - subtitle: l10n.emptyHint, - ), - ), - ], + return _refreshableState( + child: EmptyState( + key: const Key('watch-records-empty'), + icon: Icons.history_rounded, + title: l10n.noWatchRecords, + subtitle: l10n.emptyHint, + stretchContent: true, + ), ); } return _refreshable( @@ -184,6 +174,17 @@ class _WatchRecordsBodyState extends State<_WatchRecordsBody> { ); } + Widget _refreshableState({required Widget child}) { + return AppPullToRefresh( + onRefresh: controller.load, + child: CustomScrollView( + key: const Key('watch-records-empty-scroll'), + physics: AppPullToRefresh.physics, + slivers: [SliverFillRemaining(hasScrollBody: false, child: child)], + ), + ); + } + bool _onScroll(ScrollNotification notification) { if (notification.depth != 0) return false; final reachedBottom = @@ -303,6 +304,7 @@ class _FailureState extends StatelessWidget { actionLabel: l10n.retry, actionKey: const Key('watch-records-retry'), onAction: () => unawaited(onRetry()), + stretchContent: true, ); } } diff --git a/lib/pages/watch_records/widgets/watch_record_card.dart b/lib/pages/watch_records/widgets/watch_record_card.dart index 7300b39..a784374 100644 --- a/lib/pages/watch_records/widgets/watch_record_card.dart +++ b/lib/pages/watch_records/widgets/watch_record_card.dart @@ -70,9 +70,11 @@ class WatchRecordCard extends StatelessWidget { key: ValueKey('watch-record-cover-${record.dramaId}'), width: _coverWidth, height: _coverHeight, - showFallbackIcon: true, - fallbackIconKey: ValueKey( - 'watch-record-cover-placeholder-${record.dramaId}', + fallbackWidget: ColoredBox( + key: ValueKey( + 'watch-record-cover-placeholder-${record.dramaId}', + ), + color: AppColors.raised, ), ), ), diff --git a/pubspec.yaml b/pubspec.yaml index 355c0f8..10dcd44 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -146,12 +146,12 @@ flutter_launcher_icons: android: true ios: true image_path: assets/icon/ymb.png - adaptive_icon_background: "#E9142E" + adaptive_icon_background: "#000000" adaptive_icon_foreground: assets/icon/ymb_foreground.png remove_alpha_ios: true # 开屏页资源目前是手工维护的:Android 走 res/drawable*/launch_background.xml -# (黑底暗红渐变 + 居中标识),iOS 走 LaunchImage.imageset 的完整参考图。 +# (黑底白字标居中),iOS 走 LaunchImage.imageset 的完整参考图。 # 不要再执行 `dart run flutter_native_splash:create`,它只会按 color 重新生成, # 会覆盖固定尺寸布局。使用 python3 tools/generate_brand_assets.py 生成资源。 flutter_native_splash: diff --git a/test/category/category_controller_test.dart b/test/category/category_controller_test.dart index b67a7eb..9bab631 100644 --- a/test/category/category_controller_test.dart +++ b/test/category/category_controller_test.dart @@ -4,6 +4,7 @@ import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; import 'package:fruitshorts/core/data/models/content_category.dart'; +import 'package:fruitshorts/core/data/models/category_ranking.dart'; import 'package:fruitshorts/core/data/models/drama.dart'; import 'package:fruitshorts/core/data/models/home_feed.dart'; import 'package:fruitshorts/core/data/remote/api_client.dart'; @@ -31,7 +32,13 @@ void main() { 'code': 0, 'message': 'ok', 'data': [ - {'id': id, 'name': 'Romance'}, + { + 'id': id, + 'name': '推荐榜', + 'code': 'RECOMMEND_RANKING', + 'layoutType': 'RANKING', + 'revision': '6', + }, ], }), ), @@ -44,11 +51,82 @@ void main() { final categories = await CategoryApi(client).list(); expect(categories.single.id, id); - expect(categories.single.name, 'Romance'); + expect(categories.single.name, '推荐榜'); + expect(categories.single.code, 'RECOMMEND_RANKING'); + expect(categories.single.isRanking, isTrue); + expect(categories.single.revision, '6'); expect(adapter.requests.single.uri.path, '/app/categories'); }, ); + test('should_parse_ranking_snapshot_without_reordering_entries', () async { + final adapter = FakeHttpAdapter( + (_) async => FakeHttpResponse( + jsonEncode({ + 'code': 0, + 'message': 'ok', + 'data': { + 'id': '7', + 'code': 'RECOMMEND_RANKING', + 'revision': '9', + 'list': [ + { + 'rank': 1, + 'drama': { + 'id': '22', + 'titleLocales': {'zh': '第二十二部'}, + }, + }, + { + 'rank': 2, + 'drama': { + 'id': '11', + 'titleLocales': {'zh': '第十一部'}, + }, + }, + ], + }, + }), + ), + ); + final api = CategoryApi( + ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter), + ); + + final ranking = await api.ranking('7'); + + expect(ranking.revision, '9'); + expect(ranking.entries.map((item) => item.rank), [1, 2]); + expect(ranking.entries.map((item) => item.drama.id), ['22', '11']); + expect(adapter.requests.single.uri.path, '/app/categories/7/ranking'); + }); + + test('should_load_ranking_when_first_category_uses_ranking_layout', () async { + final repository = ScriptedCategoryRepository( + categories: const [ + ContentCategory( + id: '7', + name: '推荐榜', + layoutType: ContentCategory.rankingLayout, + ), + ], + ); + final controller = CategoryController(repository); + controller.dramaPageLoader = (_, {pageIndex = 1, pageSize = 20}) async => + throw StateError('grid loader should not run'); + controller.rankingLoader = (_) async => CategoryRanking( + id: '7', + code: 'RECOMMEND_RANKING', + revision: '3', + entries: [CategoryRankingEntry(rank: 1, drama: _drama('rank-1'))], + ); + + await controller.load(); + + expect(controller.rankingSlotOf('7').ranking?.revision, '3'); + expect(controller.slotOf('7').pageIndex, 0); + }); + test('should_return_category_dramas_when_category_id_exists', () async { final repository = ScriptedCategoryRepository( categories: const [ diff --git a/test/config/brand_identity_test.dart b/test/config/brand_identity_test.dart index 4fd9d41..eb2c5d5 100644 --- a/test/config/brand_identity_test.dart +++ b/test/config/brand_identity_test.dart @@ -14,7 +14,7 @@ const _launchWidth = 390; const _launchHeight = 844; const _androidRoot = 'android/app/src/main/res'; const _iosRoot = 'ios/Runner/Assets.xcassets'; -const _red = [245, 48, 59, 255]; +const _ink = [0, 0, 0, 255]; void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -32,9 +32,9 @@ void main() { expect(image.width, _iconSize); expect(image.height, _iconSize); final bytes = await image.toByteData(); - final pixel = bytes!.buffer.asUint8List().sublist(0, _red.length); - for (var channel = 0; channel < _red.length; channel++) { - expect(pixel[channel], closeTo(_red[channel], 2)); + final pixel = bytes!.buffer.asUint8List().sublist(0, _ink.length); + for (var channel = 0; channel < _ink.length; channel++) { + expect(pixel[channel], closeTo(_ink[channel], 2)); } }); @@ -84,7 +84,8 @@ void main() { final layers = File( '$_androidRoot/drawable$qualifier/launch_background.xml', ).readAsStringSync(); - expect(layers, contains('(paintFinder); - var repaints = 0; - paint.foregroundPainter!.addListener(() => repaints++); - await tester.pump(_frame); - expect(repaints, greaterThan(0)); - expect(tester.getSize(shimmer), _size); - expect( - find.descendant(of: shimmer, matching: find.byType(ClipRect)), - findsOneWidget, - ); - await tester.pumpWidget(const SizedBox.shrink()); - await tester.pump(_frame); - expect(tester.binding.hasScheduledFrame, isFalse); - }); - - testWidgets('should_pause_sweep_when_motion_is_disabled_or_page_is_hidden', ( - tester, - ) async { - await tester.pumpWidget(_placeholder()); - await tester.pump(_frame); - await tester.pumpWidget(_placeholder(reduceMotion: true)); - await tester.pump(_frame); - expect(tester.binding.hasScheduledFrame, isFalse); - await tester.pumpWidget(_placeholder(active: false)); - await tester.pump(_frame); - expect(tester.binding.hasScheduledFrame, isFalse); - await tester.pumpWidget(_placeholder()); - await tester.pump(_frame); - expect(tester.binding.hasScheduledFrame, isTrue); - await tester.pumpWidget(const SizedBox.shrink()); - }); - - testWidgets( - 'should_sweep_only_default_loading_fallback_when_image_is_pending', - (tester) async { - await tester.pumpWidget( - const MaterialApp( - home: SizedBox( - width: _width, - height: _height, - child: RemoteImage('/files/pending-cover.jpg'), - ), - ), - ); - final image = tester.widget(find.byType(Image)); - final context = tester.element(find.byType(RemoteImage)); - const loaded = SizedBox(key: Key('loaded')); - expect( - image.frameBuilder!(context, loaded, null, false), - isA(), - ); - expect(image.frameBuilder!(context, loaded, 0, false), same(loaded)); - expect( - image.errorBuilder!(context, Exception('failed'), null), - isNot(isA()), - ); - await tester.pumpWidget(const MaterialApp(home: RemoteImage(''))); - expect(find.byType(ImagePlaceholderShimmer), findsNothing); - await tester.pumpWidget( - const MaterialApp( - home: RemoteImage( - '/files/custom-cover.jpg', - fallbackWidget: SizedBox(key: Key('custom')), - ), - ), - ); - expect(find.byKey(const Key('custom')), findsOneWidget); - expect(find.byType(ImagePlaceholderShimmer), findsNothing); - await tester.pumpWidget(const SizedBox.shrink()); - }, - ); -} diff --git a/test/discover/theater_pages_test.dart b/test/discover/theater_pages_test.dart index 9c57d07..e556a4e 100644 --- a/test/discover/theater_pages_test.dart +++ b/test/discover/theater_pages_test.dart @@ -6,7 +6,9 @@ import 'package:fruitshorts/app.dart'; import 'package:fruitshorts/core/data/models/drama.dart'; import 'package:fruitshorts/core/data/models/home_feed.dart'; import 'package:fruitshorts/core/theme/app_colors.dart'; +import 'package:fruitshorts/core/theme/app_dimens.dart'; import 'package:fruitshorts/core/widgets/app_pull_to_refresh.dart'; +import 'package:fruitshorts/core/widgets/paged_local_list.dart'; import 'package:fruitshorts/pages/discover/discover_view.dart'; import 'package:fruitshorts/pages/discover/widgets/drama_grid.dart'; import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; @@ -34,7 +36,17 @@ void main() { expect(find.byKey(const Key('ranking-page')), findsOneWidget); _expectUnifiedHeader(tester, const Key('ranking-back')); - expect(find.text('TOP 1'), findsOneWidget); + expect( + tester + .widget( + find.descendant( + of: find.byKey(const Key('ranking-top-1')), + matching: find.byType(Text), + ), + ) + .data, + '1', + ); expect( tester.widget(find.byKey(const Key('ranking-title-1'))).data, 'The Signal', @@ -105,51 +117,72 @@ void main() { expect(find.byKey(const Key('new-releases-page')), findsOneWidget); }); - testWidgets('should_render_podium_with_center_on_top', (tester) async { + testWidgets('should_render_uniform_rows_with_cover_badges', (tester) async { await _pumpTheater(tester); await tester.tap(find.byKey(const Key('theater-toolbar-ranking'))); await tester.pumpAndSettle(); - final top1 = tester.getRect(find.byKey(const Key('ranking-cover-1'))); - final top2 = tester.getRect(find.byKey(const Key('ranking-cover-2'))); - final top3 = tester.getRect(find.byKey(const Key('ranking-cover-3'))); - expect(top1.width, greaterThan(top2.width)); - expect(top3.width, closeTo(top2.width, 0.5)); - expect(top2.left, closeTo(8, 0.5)); - expect(top3.right, closeTo(382, 0.5)); - expect(top1.left - top2.right, lessThanOrEqualTo(10)); - expect(top2.width, greaterThan(100)); - expect(top2.left, lessThan(top1.left)); - expect(top3.left, greaterThan(top1.right)); - expect(top1.bottom, closeTo(top2.bottom - 30, 0.5)); - expect(top2.bottom, closeTo(top3.bottom, 0.5)); - expect(find.byKey(const Key('ranking-row-3')), findsNothing); + for (var rank = 1; rank <= 3; rank++) { + expect(find.byKey(Key('ranking-row-${rank - 1}')), findsOneWidget); + expect( + tester.getSize(find.byKey(Key('ranking-row-cover-$rank'))), + const Size(96, 128), + ); + final badge = tester.widget( + find.byKey(Key('ranking-top-$rank')), + ); + final decoration = badge.decoration! as BoxDecoration; + final expected = switch (rank) { + 1 => AppColors.brandRed, + 2 => AppColors.homeCrownGold, + _ => AppColors.bronze, + }; + expect(decoration.color, expected); + } }); - testWidgets('should_list_rank_four_and_below', (tester) async { + testWidgets('should_list_every_rank_as_row', (tester) async { await _pumpTheater(tester, dramas: _rankDramas(5)); await tester.tap(find.byKey(const Key('theater-toolbar-ranking'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('ranking-row-3')), findsOneWidget); - expect(find.byKey(const Key('ranking-row-2')), findsNothing); + for (var index = 0; index < 5; index++) { + expect(find.byKey(Key('ranking-row-$index')), findsOneWidget); + } final rankNumber = tester.widget( - find.byKey(const Key('ranking-top-4')), + find.descendant( + of: find.byKey(const Key('ranking-top-4')), + matching: find.byType(Text), + ), ); expect(rankNumber.data, '4'); - expect(rankNumber.style?.fontSize, 48); - expect(rankNumber.style?.fontWeight, FontWeight.w800); + expect(rankNumber.style?.fontSize, 14); + expect(rankNumber.style?.fontWeight, FontWeight.w700); + final badge = tester.widget( + find.byKey(const Key('ranking-top-4')), + ); + expect( + tester.getSize(find.byKey(const Key('ranking-top-4'))), + const Size(24, 24), + ); + expect( + (badge.decoration! as BoxDecoration).borderRadius, + const BorderRadius.only( + topLeft: Radius.circular(AppDimens.rankingCoverRadius), + bottomRight: Radius.circular(10), + ), + ); expect( tester.getSize(find.byKey(const Key('ranking-row-3'))), - const Size(362, 96), + const Size(362, 128), ); expect( tester.getSize(find.byKey(const Key('ranking-row-cover-4'))), - const Size(60, 80), + const Size(96, 128), ); }); - testWidgets('should_truncate_podium_when_less_than_three', (tester) async { + testWidgets('should_render_single_row_when_only_one_drama', (tester) async { await _pumpTheater(tester, dramas: _rankDramas(1)); await tester.tap(find.byKey(const Key('theater-toolbar-ranking'))); await tester.pumpAndSettle(); @@ -194,14 +227,18 @@ void main() { final list = find.byKey(const Key('ranking-list')); expect(tester.widget(list).physics, AppPullToRefresh.physics); final position = _scrollPosition(tester, const Key('ranking-list')); + // jumpTo 不发滚动通知,滑到底后再拖一下才会触发加载。 position.jumpTo(position.maxScrollExtent); await tester.pump(); + await tester.drag(list, const Offset(0, -60)); + await tester.pump(); expect(find.byKey(const Key('ranking-row-19')), findsOneWidget); expect(find.byKey(const Key('ranking-row-20')), findsNothing); - expect(find.byKey(const Key('ranking-load-more')), findsOneWidget); + // 加载指示出现后会撑高列表,可视区外被 finder 视为 offstage,直接看状态。 + expect(_loadFooter(tester).loading, isTrue); await tester.pump(const Duration(milliseconds: 299)); - expect(find.byKey(const Key('ranking-load-more')), findsOneWidget); + expect(_loadFooter(tester).loading, isTrue); await tester.pump(const Duration(milliseconds: 1)); position.jumpTo(position.maxScrollExtent); @@ -219,11 +256,14 @@ void main() { expect(repository.fetchCalls, beforeRefresh + 1); expect(position.pixels, 0); - position.jumpTo(position.maxScrollExtent); - await tester.pump(); - expect(find.byKey(const Key('ranking-load-more')), findsOneWidget); - expect(find.byKey(const Key('ranking-no-more')), findsNothing); + // 刷新重置回 20 项后,滑到底再触发一次加载,应恢复 40 项。 + final refreshed = _scrollPosition(tester, const Key('ranking-list')); + refreshed.jumpTo(refreshed.maxScrollExtent); await tester.pumpAndSettle(); + refreshed.jumpTo(refreshed.maxScrollExtent); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('ranking-row-39')), findsOneWidget); + expect(find.byKey(const Key('ranking-no-more')), findsNothing); }); testWidgets('should_page_new_releases_and_reset_after_refresh', ( @@ -311,10 +351,9 @@ Future _pumpTheater( ), ); await tester.pumpAndSettle(); - Navigator.of(tester.element(find.byKey(const Key('home-tabs')))) - .push( - MaterialPageRoute(builder: (_) => const DiscoverPage()), - ); + Navigator.of( + tester.element(find.byKey(const Key('home-tabs'))), + ).push(MaterialPageRoute(builder: (_) => const DiscoverPage())); await tester.pumpAndSettle(); } @@ -326,6 +365,15 @@ ScrollPosition _scrollPosition(WidgetTester tester, Key listKey) { return tester.state(scrollable.first).position; } +PagedLoadFooter _loadFooter(WidgetTester tester) { + return tester.widget( + find.descendant( + of: find.byKey(const Key('ranking-page'), skipOffstage: false), + matching: find.byType(PagedLoadFooter, skipOffstage: false), + ), + ); +} + class _CountingCatalogRepository extends FakeCatalogRepository { _CountingCatalogRepository({super.dramas}); diff --git a/test/empty_state/empty_state_test.dart b/test/empty_state/empty_state_test.dart index 1c32c9d..8e5027b 100644 --- a/test/empty_state/empty_state_test.dart +++ b/test/empty_state/empty_state_test.dart @@ -69,7 +69,7 @@ void main() { semantics.dispose(); }); - testWidgets('should_render_compact_dimensions_for_embedded_empty_state', ( + testWidgets('should_keep_standard_icon_size_in_compact_empty_state', ( tester, ) async { await _pump( @@ -83,7 +83,7 @@ void main() { expect( tester.getSize(find.byKey(const Key('empty-state-icon'))), - const Size(44, 44), + const Size(72, 72), ); expect(find.byType(DecoratedBox), findsNothing); expect( @@ -97,7 +97,7 @@ void main() { tester .widget(find.byKey(const Key('empty-state-padding'))) .padding, - const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + const EdgeInsets.symmetric(horizontal: 16, vertical: 4), ); expect(find.byKey(const Key('empty-state-action')), findsNothing); }); diff --git a/test/home/home_ranking_dramas_test.dart b/test/home/home_ranking_dramas_test.dart new file mode 100644 index 0000000..c0a774c --- /dev/null +++ b/test/home/home_ranking_dramas_test.dart @@ -0,0 +1,86 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/category_ranking.dart'; +import 'package:fruitshorts/core/data/models/content_category.dart'; +import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/models/home_feed.dart'; +import 'package:fruitshorts/core/data/repositories/category_repository.dart'; +import 'package:fruitshorts/core/data/services/category_controller.dart'; +import 'package:fruitshorts/core/theme/app_colors.dart'; +import 'package:fruitshorts/core/theme/app_theme.dart'; +import 'package:fruitshorts/l10n/app_localizations.dart'; +import 'package:fruitshorts/pages/home/widgets/home_ranking_dramas.dart'; +import 'package:provider/provider.dart'; + +void main() { + testWidgets('should_render_uniform_rows_in_server_rank_order', ( + tester, + ) async { + final controller = CategoryController(_EmptyCategoryRepository()); + controller.rankingSlotOf('ranking').ranking = CategoryRanking( + id: 'ranking', + code: 'RECOMMEND_RANKING', + revision: '4', + entries: [ + for (var rank = 1; rank <= 4; rank++) + CategoryRankingEntry(rank: rank, drama: _drama('$rank')), + ], + ); + Drama? opened; + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: controller, + child: MaterialApp( + theme: AppTheme.dark(), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: HomeRankingDramas( + categoryId: 'ranking', + onOpenDrama: (drama) => opened = drama, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + for (var rank = 1; rank <= 4; rank++) { + expect(find.byKey(Key('ranking-top-$rank')), findsOneWidget); + expect(find.byKey(Key('ranking-row-${rank - 1}')), findsOneWidget); + } + expect(find.byKey(const Key('home-ranking-top-1')), findsNothing); + final surface = tester.widget( + find.byKey(const Key('home-ranking-surface')), + ); + final gradient = + (surface.decoration as BoxDecoration).gradient! as LinearGradient; + expect(gradient.colors.first, AppColors.followingGlow); + expect( + tester.getSize(find.byKey(const Key('ranking-row-cover-1'))), + const Size(96, 128), + ); + + await tester.tap(find.byKey(const Key('ranking-top-2'))); + expect(opened?.id, '2'); + }); +} + +Drama _drama(String id) => Drama( + id: id, + title: 'Rank $id', + synopsis: '', + posterUrl: '', + viewCount: int.parse(id) * 10, +); + +class _EmptyCategoryRepository implements CategoryRepository { + @override + Future> fetchCategories() async => const []; + + @override + Future fetchDiscoverFeed() async => const HomeFeed(); +} diff --git a/test/library/favorites_page_test.dart b/test/library/favorites_page_test.dart index 69c08ce..f0306bb 100644 --- a/test/library/favorites_page_test.dart +++ b/test/library/favorites_page_test.dart @@ -174,6 +174,14 @@ void main() { expect(find.byKey(const Key('favorites-empty')), findsOneWidget); expect(find.byType(EmptyState), findsOneWidget); + final viewport = tester.getRect( + find.byKey(const Key('favorites-empty-scroll')), + ); + final content = tester.getRect( + find.byKey(const Key('empty-state-content')), + ); + expect(content.center.dx, closeTo(viewport.center.dx, 0.1)); + expect(content.center.dy, closeTo(viewport.center.dy, 0.1)); }); testWidgets('should_show_retry_when_favorite_list_fails', (tester) async { diff --git a/test/ranking/ranking_page_test.dart b/test/ranking/ranking_page_test.dart new file mode 100644 index 0000000..534297e --- /dev/null +++ b/test/ranking/ranking_page_test.dart @@ -0,0 +1,126 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/models/feed_page.dart'; +import 'package:fruitshorts/core/data/models/home_feed.dart'; +import 'package:fruitshorts/core/data/repositories/catalog_repository.dart'; +import 'package:fruitshorts/core/data/services/catalog_controller.dart'; +import 'package:fruitshorts/core/theme/app_theme.dart'; +import 'package:fruitshorts/l10n/app_localizations.dart'; +import 'package:fruitshorts/pages/ranking/ranking_page.dart'; +import 'package:provider/provider.dart'; + +void main() { + testWidgets('should_render_rank_badges_on_covers_for_all_entries', ( + tester, + ) async { + await _pumpPage(tester, [ + _drama('a', viewCount: 400), + _drama('b', viewCount: 300), + _drama('c', viewCount: 200), + _drama('d', viewCount: 100), + ]); + + expect(find.byKey(const Key('ranking-top-1')), findsOneWidget); + expect(find.byKey(const Key('ranking-top-2')), findsOneWidget); + expect(find.byKey(const Key('ranking-top-3')), findsOneWidget); + expect(find.byKey(const Key('ranking-top-4')), findsOneWidget); + expect(find.byKey(const Key('ranking-row-cover-1')), findsOneWidget); + expect(find.byKey(const Key('ranking-title-1')), findsOneWidget); + }); + + testWidgets('should_hide_optional_fields_when_drama_has_no_data', ( + tester, + ) async { + await _pumpPage(tester, [_drama('solo')]); + + expect(find.byKey(const Key('ranking-tag-1')), findsNothing); + expect(find.byIcon(Icons.local_fire_department_rounded), findsNothing); + }); + + testWidgets('should_show_optional_fields_when_present', (tester) async { + await _pumpPage(tester, [ + _drama( + 'rich', + viewCount: 15340000, + likeCount: 550, + tag: '新剧', + genre: '剧情', + synopsis: '以拟人化视角,重温长征岁月。', + ), + ]); + + expect(find.byKey(const Key('ranking-tag-1')), findsOneWidget); + expect(find.text('新剧'), findsOneWidget); + expect(find.text('剧情'), findsOneWidget); + expect(find.text('以拟人化视角,重温长征岁月。'), findsOneWidget); + expect(find.text('1534w heat · 550 likes'), findsOneWidget); + final synopsis = tester.widget(find.text('以拟人化视角,重温长征岁月。')); + expect(synopsis.maxLines, 3); + expect(synopsis.overflow, TextOverflow.ellipsis); + final tag = tester.widget(find.byKey(const Key('ranking-tag-1'))); + expect(tag.style?.color, isNotNull); + final synopsisRect = tester.getRect(find.text('以拟人化视角,重温长征岁月。')); + final tagRect = tester.getRect(find.byKey(const Key('ranking-tag-1'))); + expect(tagRect.top - synopsisRect.bottom, lessThan(8)); + }); +} + +Future _pumpPage(WidgetTester tester, List dramas) async { + final controller = CatalogController(_EmptyCatalogRepository()); + controller.feed = HomeFeed( + sections: [FeedSection(code: 'hot', title: 'Hot', dramas: dramas)], + ); + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: controller, + child: MaterialApp( + theme: AppTheme.dark(), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const RankingPage(), + ), + ), + ); + await tester.pumpAndSettle(); +} + +Drama _drama( + String id, { + int viewCount = 0, + int? likeCount, + String tag = '', + String genre = '', + String synopsis = '', +}) => Drama( + id: id, + title: 'Drama $id', + synopsis: synopsis, + posterUrl: '', + tag: tag, + genre: genre, + viewCount: viewCount, + likeCount: likeCount, +); + +class _EmptyCatalogRepository implements CatalogRepository { + @override + Future fetchHomeFeed() async => const HomeFeed(); + + @override + Future fetchDiscoverFeed() async => const HomeFeed(); + + @override + Future fetchFeed({ + List types = const [], + String? categoryId, + String? cursor, + int size = 10, + }) async => const FeedPage(); + + @override + Future fetchDrama(String dramaId) async => + Drama(id: dramaId, title: '', synopsis: '', posterUrl: ''); +} diff --git a/test/recommend/recommend_discover_empty_state_test.dart b/test/recommend/recommend_discover_empty_state_test.dart new file mode 100644 index 0000000..190d6f6 --- /dev/null +++ b/test/recommend/recommend_discover_empty_state_test.dart @@ -0,0 +1,138 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/category_ranking.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/category_controller.dart'; +import 'package:fruitshorts/l10n/app_localizations.dart'; +import 'package:fruitshorts/pages/recommend/recommend_discover_page.dart'; +import 'package:provider/provider.dart'; + +import '../support/fake_catalog.dart'; +import '../support/fake_category.dart'; + +void main() { + testWidgets('should_center_empty_state_when_category_has_no_dramas', ( + tester, + ) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final controller = CategoryController( + FakeCategoryRepository(catalog: FakeCatalogRepository()), + ); + controller.categories = const [ContentCategory(id: '7', name: 'Romance')]; + controller.dramaPageLoader = + (categoryId, {pageIndex = 1, pageSize = 20}) async => + const PageResult(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + locale: const Locale('zh'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: ChangeNotifierProvider.value( + value: controller, + child: const RecommendDiscoverPage(active: true, onSearch: _noop), + ), + ), + ); + await tester.pump(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 1)); + + expect(find.text('暂无剧目'), findsOneWidget); + expect(find.text('暂无剧目。'), findsNothing); + + final content = tester.getRect( + find.byKey(const Key('empty-state-content')), + ); + final icon = tester.getRect(find.byKey(const Key('empty-state-icon'))); + final title = tester.getRect(find.byKey(const Key('empty-state-title'))); + final viewport = tester.view.physicalSize / tester.view.devicePixelRatio; + expect(content.center.dx, closeTo(viewport.width / 2, 1.0)); + expect(content.center.dy, closeTo(viewport.height / 2, 1.0)); + expect(icon.center.dx, closeTo(title.center.dx, 0.1)); + expect(title.top - icon.bottom, closeTo(12, 0.1)); + }); + + testWidgets('should_render_ranking_layout_when_category_is_ranking', ( + tester, + ) async { + final controller = CategoryController( + FakeCategoryRepository(catalog: FakeCatalogRepository()), + ); + controller.categories = const [ + ContentCategory( + id: 'ranking', + name: '推荐榜', + layoutType: ContentCategory.rankingLayout, + ), + ]; + var dramaLoads = 0; + var rankingLoads = 0; + controller.dramaPageLoader = + (categoryId, {pageIndex = 1, pageSize = 20}) async { + dramaLoads++; + return const PageResult(); + }; + controller.rankingLoader = (categoryId) async { + rankingLoads++; + return CategoryRanking( + id: categoryId, + code: 'RECOMMEND_RANKING', + revision: '2', + entries: [ + for (var rank = 1; rank <= 4; rank++) + CategoryRankingEntry(rank: rank, drama: _drama('$rank')), + ], + ); + }; + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + locale: const Locale('zh'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: ChangeNotifierProvider.value( + value: controller, + child: const RecommendDiscoverPage(active: true, onSearch: _noop), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(rankingLoads, 1); + expect(dramaLoads, 0); + expect(find.byKey(const Key('ranking-top-1')), findsOneWidget); + expect(find.byKey(const Key('ranking-row-3')), findsOneWidget); + final navUnderline = tester.getRect( + find.byKey(const Key('recommend-tab-ranking-underline')), + ); + final firstRow = tester.getRect(find.byKey(const Key('ranking-row-0'))); + final gap = firstRow.top - navUnderline.bottom; + expect(gap, greaterThan(10)); + expect(gap, lessThan(26)); + expect( + find.byKey(const Key('recommend-category-grid-ranking')), + findsNothing, + ); + }); +} + +void _noop() {} + +Drama _drama(String id) => Drama( + id: id, + title: 'Rank $id', + synopsis: '', + posterUrl: '', + viewCount: int.parse(id) * 10, +); diff --git a/test/watch/watch_records_page_test.dart b/test/watch/watch_records_page_test.dart index e797107..e7deeee 100644 --- a/test/watch/watch_records_page_test.dart +++ b/test/watch/watch_records_page_test.dart @@ -209,6 +209,27 @@ void main() { expect(find.text('The Signal'), findsOneWidget); }); + testWidgets('should_center_empty_state_in_remaining_page_space', ( + tester, + ) async { + final auth = await _auth(signedIn: true); + await _pumpPage( + tester, + auth: auth, + history: CountingWatchHistory(const []), + catalog: FakeCatalogRepository(), + ); + + final viewport = tester.getRect( + find.byKey(const Key('watch-records-empty-scroll')), + ); + final content = tester.getRect( + find.byKey(const Key('empty-state-content')), + ); + expect(content.center.dx, closeTo(viewport.center.dx, 0.1)); + expect(content.center.dy, closeTo(viewport.center.dy, 0.1)); + }); + testWidgets('should_remove_and_clear_device_history_when_signed_out', ( tester, ) async { diff --git a/tools/generate_brand_assets.py b/tools/generate_brand_assets.py index 9a7d97d..059e4b9 100644 --- a/tools/generate_brand_assets.py +++ b/tools/generate_brand_assets.py @@ -12,7 +12,7 @@ IOS = ROOT / "ios/Runner/Assets.xcassets" ANDROID = ROOT / "android/app/src/main/res" REFERENCE = ROOT / "assets/branding/ymb-reference.png" LAUNCH_REFERENCE = ROOT / "assets/branding/ymb-launch-reference.png" -BRAND_RED = "#E9142E" +BRAND_BACKGROUND = "#000000" WHITE = "#FFFFFF" CLEAR = (0, 0, 0, 0) MASTER_SIZE = 1024 @@ -104,7 +104,7 @@ def android_assets(master, foreground): ANDROID / f"drawable-{density}/ic_launcher_foreground.png", ) save( - Image.new("RGB", (pixels, pixels), BRAND_RED), + Image.new("RGB", (pixels, pixels), BRAND_BACKGROUND), ANDROID / f"mipmap-{density}/ic_launcher_background.png", ) mark = splash_icon(master, round(SPLASH_SIZE * scale)) @@ -117,10 +117,13 @@ def android_assets(master, foreground): def main(): """直接复用确认的原始图标和开屏,输出平台所需尺寸。""" reference = Image.open(REFERENCE).convert("RGB") - master = reference.resize((MASTER_SIZE, MASTER_SIZE), RESAMPLE) mark = wordmark(reference) + master = centered( + Image.new("RGBA", (MASTER_SIZE, MASTER_SIZE), (0, 0, 0, 255)), + mark.resize((MASTER_SIZE, MASTER_SIZE), RESAMPLE), + ) foreground = mark.resize((MASTER_SIZE, MASTER_SIZE), RESAMPLE) - save(master, ASSETS / "ymb.png") + save(master.convert("RGB"), ASSETS / "ymb.png") save(foreground, ASSETS / "ymb_foreground.png") placeholder = centered( Image.new("RGBA", PLACEHOLDER_SIZE, CLEAR),