This commit is contained in:
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>{},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user