chore(release): 合并 develop 到 main
flutter-ci / test (push) Failing after 19m28s

This commit is contained in:
Vicky committed 2026-11-02 14:54:00 +08:00
commit e8b9a090e0
115 files changed
+1362 -847

No files matched your search

@@ -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<String, String> locales;
final DateTime? generatedAt;
final List<CategoryRankingEntry> entries;
/// 解析 `GET /app/categories/{id}/ranking`。
factory CategoryRanking.fromJson(Map<String, dynamic> json) {
final rawList = json['list'];
final entries = rawList is List
? rawList
.whereType<Map>()
.map(
(item) =>
CategoryRankingEntry.fromJson(item.cast<String, dynamic>()),
)
.toList(growable: false)
: const <CategoryRankingEntry>[];
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<String, dynamic> json) {
final rawDrama = json['drama'];
return CategoryRankingEntry(
rank: (json['rank'] as num?)?.toInt() ?? 0,
drama: Drama.fromCardJson(
rawDrama is Map<String, dynamic>
? rawDrama
: rawDrama is Map
? rawDrama.cast<String, dynamic>()
: const <String, dynamic>{},
),
);
}
}
+19 -1
View File
@@ -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<String, dynamic> 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);
+8 -4
View File
@@ -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<CategoryRanking> ranking(String categoryId) async {
final data = await _client.get('/app/categories/$categoryId/ranking');
return CategoryRanking.fromJson(ResponseDecoders.asMap(data));
}
}
@@ -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<CategoryRanking> Function(String categoryId)? rankingLoader;
List<ContentCategory> categories = const [];
final Map<String, CategoryDramaSlot> _slots = {};
final Map<String, CategoryRankingSlot> _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<void> 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<void> 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;
}
@@ -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;
+2 -10
View File
@@ -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;
+40 -27
View File
@@ -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(
@@ -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<ImagePlaceholderShimmer> createState() =>
_ImagePlaceholderShimmerState();
}
class _ImagePlaceholderShimmerState extends State<ImagePlaceholderShimmer>
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<double> 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;
}
+11 -43
View File
@@ -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);
}
}