海报从 Empurple 的 Best50ImageRenderer/ImageBuilder 移植:按原始像素坐标 (1762x1850 画布、300x100 卡片、5 列、行距 120) 叠加在 base.png 上,底图里 已画好的面板/药丸标签/页脚不重复绘制;页面外壳走 Ichino 的 Material 3。 RatingCalculator 的达成率分档改成单张有序表,避免偏移与评级错位; RA 与评级由 test/rating_calculator_test.dart 的边界用例锁定。 GetUserRatingApi 只需要 userId(Cookie 可选),所以 Preview API 模式、未登录 游戏服务器时也能出图,只是没有 FC/AP/FS 标记。导出用 RepaintBoundary 抓 全尺寸 PNG:原生走 share_plus(不支持文件分享的平台回退为落盘并提示路径), web 走下载。 BEST35 与 BEST15 改为各自独立排版。原先沿用 Empurple 的跨列表全局序号, BEST35 没打满时 BEST15 会整体上移混进上半区。 path_provider 会经 path_provider_android 2.3.x 拖入 jni 插件,而 jni 把 windows/linux 也声明成 ffiPlugin,桌面构建因此去链接 jvm.lib 并失败, 故用 dependency_overrides 钉住 path_provider_android <2.3.0。
351 lines
10 KiB
Dart
351 lines
10 KiB
Dart
import 'package:flutter/material.dart';
|
|
|
|
import '../config/strings.dart';
|
|
import '../models/user_rating.dart';
|
|
|
|
/// 海报上一张卡片所需的数据,由调用方解析好(曲名、定数、RA)。
|
|
class Best50CardData {
|
|
final int musicId;
|
|
final MusicLevel level;
|
|
final String title;
|
|
final String achievementText;
|
|
final int comboStatus;
|
|
final int syncStatus;
|
|
final int ra;
|
|
final String rate;
|
|
|
|
const Best50CardData({
|
|
required this.musicId,
|
|
required this.level,
|
|
required this.title,
|
|
required this.achievementText,
|
|
required this.comboStatus,
|
|
required this.syncStatus,
|
|
required this.ra,
|
|
required this.rate,
|
|
});
|
|
|
|
/// DX 谱的 musicId 带 10000 偏移。
|
|
bool get isDx => musicId > 10000;
|
|
}
|
|
|
|
/// Best 50 成绩海报,按 Empurple `Best50ImageRenderer` + `ImageBuilder` 的原始
|
|
/// 像素坐标复现:底图 `assets/b50/base.png` 为 1762x1850,卡片 300x100、
|
|
/// 每行 5 张、Best35 占 0..6 行、Best15 从第 7 行起跳到 1250 那条分隔带下方。
|
|
///
|
|
/// 顶部面板、舞萌DX logo、Best 35 / Best 15 药丸标签与页脚线都已经画在底图里,
|
|
/// 这里只叠加头像、昵称、Rating 行、50 张卡和页脚文字。
|
|
class Best50Poster extends StatelessWidget {
|
|
static const double canvasWidth = 1762;
|
|
static const double canvasHeight = 1850;
|
|
static const double exportPixelRatio = 2;
|
|
|
|
static const int maxBest35 = 35;
|
|
static const int maxBest15 = 15;
|
|
|
|
// 卡片栅格(与 Empurple 一致)
|
|
static const int columnsPerRow = 5;
|
|
static const double cardWidth = 300;
|
|
static const double cardHeight = 100;
|
|
static const double columnPitch = 320;
|
|
static const double rowPitch = 120;
|
|
static const double gridLeft = 65;
|
|
static const double gridTopFirstBlock = 350;
|
|
static const double gridTopSecondBlock = 1250;
|
|
|
|
static const Color inkColor = Color(0xFF3D3D3D);
|
|
static const Color cardColor = Color(0xFFEDEAFF);
|
|
|
|
final String userName;
|
|
final int iconId;
|
|
final int playerRating;
|
|
final List<Best50CardData> best35;
|
|
final List<Best50CardData> best15;
|
|
|
|
const Best50Poster({
|
|
super.key,
|
|
required this.userName,
|
|
required this.iconId,
|
|
required this.playerRating,
|
|
required this.best35,
|
|
required this.best15,
|
|
});
|
|
|
|
/// 两个区块各自独立排版:BEST35 固定从 [gridTopFirstBlock] 起、BEST15 固定从
|
|
/// [gridTopSecondBlock] 起。不能按「35+15 拼接后的全局序号」算行,否则 BEST35
|
|
/// 没打满时 BEST15 会整体上移、混进上半区。
|
|
static double cardLeft(int indexInBlock) =>
|
|
gridLeft + columnPitch * (indexInBlock % columnsPerRow);
|
|
|
|
static double cardTopBest35(int indexInBlock) =>
|
|
gridTopFirstBlock + rowPitch * (indexInBlock ~/ columnsPerRow);
|
|
|
|
static double cardTopBest15(int indexInBlock) =>
|
|
gridTopSecondBlock + rowPitch * (indexInBlock ~/ columnsPerRow);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final cards35 = best35.take(maxBest35).toList();
|
|
final cards15 = best15.take(maxBest15).toList();
|
|
final ra35 = cards35.fold(0, (sum, card) => sum + card.ra);
|
|
final ra15 = cards15.fold(0, (sum, card) => sum + card.ra);
|
|
|
|
return SizedBox(
|
|
width: canvasWidth,
|
|
height: canvasHeight,
|
|
child: Stack(
|
|
clipBehavior: Clip.hardEdge,
|
|
children: [
|
|
Positioned.fill(
|
|
child: Image.asset(
|
|
'assets/b50/base.png',
|
|
fit: BoxFit.fill,
|
|
filterQuality: FilterQuality.medium,
|
|
),
|
|
),
|
|
if (iconId > 0)
|
|
Positioned(
|
|
left: 115,
|
|
top: 108,
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(24),
|
|
child: Image.network(
|
|
'https://assets2.lxns.net/maimai/icon/$iconId.png',
|
|
width: 112,
|
|
height: 112,
|
|
fit: BoxFit.cover,
|
|
errorBuilder: (_, _, _) => const SizedBox.shrink(),
|
|
),
|
|
),
|
|
),
|
|
Positioned(
|
|
left: 260,
|
|
top: 118,
|
|
child: Text(
|
|
userName.isEmpty ? AppStrings.unknownUser : userName,
|
|
style: TextStyle(
|
|
fontSize: 48,
|
|
fontWeight: FontWeight.bold,
|
|
height: 1.15,
|
|
color: inkColor,
|
|
fontFamily: theme.textTheme.bodyLarge?.fontFamily,
|
|
),
|
|
),
|
|
),
|
|
Positioned(
|
|
left: 260,
|
|
top: 178,
|
|
child: Text(
|
|
AppStrings.best50RatingSum(ra35, ra15),
|
|
style: const TextStyle(
|
|
fontSize: 24,
|
|
height: 1.15,
|
|
color: inkColor,
|
|
),
|
|
),
|
|
),
|
|
Positioned(
|
|
left: 260,
|
|
top: 204,
|
|
child: Text(
|
|
AppStrings.rating(playerRating),
|
|
style: const TextStyle(
|
|
fontSize: 24,
|
|
height: 1.15,
|
|
color: inkColor,
|
|
),
|
|
),
|
|
),
|
|
for (var i = 0; i < cards35.length; i++)
|
|
Positioned(
|
|
left: cardLeft(i),
|
|
top: cardTopBest35(i),
|
|
child: _RatingCard(data: cards35[i]),
|
|
),
|
|
for (var i = 0; i < cards15.length; i++)
|
|
Positioned(
|
|
left: cardLeft(i),
|
|
top: cardTopBest15(i),
|
|
child: _RatingCard(data: cards15[i]),
|
|
),
|
|
Positioned(
|
|
left: 70,
|
|
top: 1718,
|
|
child: Text(
|
|
AppStrings.best50Footer,
|
|
style: const TextStyle(
|
|
fontSize: 24,
|
|
height: 1.15,
|
|
color: inkColor,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _RatingCard extends StatelessWidget {
|
|
final Best50CardData data;
|
|
|
|
const _RatingCard({required this.data});
|
|
|
|
static const Map<int, String> comboAssets = {
|
|
1: 'FC',
|
|
2: 'FCp',
|
|
3: 'AP',
|
|
4: 'APp',
|
|
};
|
|
static const Map<int, String> syncAssets = {
|
|
1: 'FS',
|
|
2: 'FSp',
|
|
3: 'FSD',
|
|
4: 'FSDp',
|
|
};
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return SizedBox(
|
|
width: Best50Poster.cardWidth,
|
|
height: Best50Poster.cardHeight,
|
|
child: Stack(
|
|
clipBehavior: Clip.hardEdge,
|
|
children: [
|
|
Positioned.fill(
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
color: Best50Poster.cardColor,
|
|
borderRadius: BorderRadius.circular(30),
|
|
),
|
|
),
|
|
),
|
|
Positioned(
|
|
left: 10,
|
|
top: 10,
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(20),
|
|
child: Image.network(
|
|
'https://assets2.lxns.net/maimai/jacket/'
|
|
'${data.isDx ? data.musicId - 10000 : data.musicId}.png',
|
|
width: 80,
|
|
height: 80,
|
|
fit: BoxFit.cover,
|
|
errorBuilder: (_, _, _) => Container(
|
|
width: 80,
|
|
height: 80,
|
|
color: Colors.black12,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
Positioned(
|
|
left: 100,
|
|
top: 20,
|
|
width: data.isDx ? 124 : 188,
|
|
child: Text(
|
|
data.title,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(
|
|
fontSize: 16,
|
|
height: 1.15,
|
|
color: Best50Poster.inkColor,
|
|
),
|
|
),
|
|
),
|
|
Positioned(
|
|
left: 100,
|
|
top: 40,
|
|
child: Text(
|
|
data.achievementText,
|
|
style: const TextStyle(
|
|
fontSize: 26,
|
|
height: 1.15,
|
|
fontWeight: FontWeight.bold,
|
|
color: Best50Poster.inkColor,
|
|
),
|
|
),
|
|
),
|
|
if (data.isDx)
|
|
Positioned(
|
|
left: 230,
|
|
top: 20,
|
|
child: Image.asset(
|
|
'assets/b50/DX.png',
|
|
width: 60,
|
|
height: 21,
|
|
fit: BoxFit.contain,
|
|
),
|
|
),
|
|
Positioned(
|
|
left: 95,
|
|
top: 67,
|
|
child: Image.asset(
|
|
'assets/b50/UI_TTR_Rank_${data.rate}.png',
|
|
width: 55,
|
|
height: 28,
|
|
fit: BoxFit.contain,
|
|
),
|
|
),
|
|
if (comboAssets[data.comboStatus] case final asset?)
|
|
Positioned(
|
|
left: 148,
|
|
top: 66,
|
|
child: Image.asset(
|
|
'assets/b50/UI_CHR_PlayBonus_$asset.png',
|
|
width: 32,
|
|
height: 32,
|
|
fit: BoxFit.contain,
|
|
),
|
|
),
|
|
if (syncAssets[data.syncStatus] case final asset?)
|
|
Positioned(
|
|
left: 178,
|
|
top: 66,
|
|
child: Image.asset(
|
|
'assets/b50/UI_CHR_PlayBonus_$asset.png',
|
|
width: 32,
|
|
height: 32,
|
|
fit: BoxFit.contain,
|
|
),
|
|
),
|
|
Positioned(
|
|
left: 225,
|
|
top: 70,
|
|
child: Container(
|
|
width: 68,
|
|
height: 26,
|
|
alignment: Alignment.center,
|
|
decoration: BoxDecoration(
|
|
color: _LevelPill.background[data.level],
|
|
borderRadius: BorderRadius.circular(13),
|
|
),
|
|
child: Text(
|
|
'RA ${data.ra}',
|
|
style: const TextStyle(
|
|
fontSize: 12,
|
|
height: 1.0,
|
|
color: Best50Poster.inkColor,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 难度色是游戏内语义色,与 Empurple 一致,不映射到 Material 色板。
|
|
class _LevelPill {
|
|
static const Map<MusicLevel, Color> background = {
|
|
MusicLevel.basic: Color(0xFF628C7B),
|
|
MusicLevel.advanced: Color(0xFFB58300),
|
|
MusicLevel.expert: Color(0xFFD37A7A),
|
|
MusicLevel.master: Color(0xFF6750A4),
|
|
MusicLevel.reMaster: Color(0xFFD0C8FF),
|
|
};
|
|
}
|