feat: 移植 B50 成绩图,并把项目名从 purarine 改回 ichino

海报从 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。
This commit is contained in:
Cookiues committed 2026-09-25 22:25:35 +08:00
1 parent 24ccd628d1
commit 79fa0d7a20
67 files changed
+1563 -62

No files matched your search

+19 -2
View File
@@ -4,12 +4,13 @@ class AppStrings {
// Tab
static const tabHome = '主页';
static const tabTickets = '票据';
static const tabBest50 = 'B50';
static const tabRisk = '风险';
static const tabSettings = '设置';
static const tabAbout = '关于';
// Login
static const appTitle = 'Purarine';
static const appTitle = 'Ichino';
static const loginSubtitle = '扫描或输入二维码进行登录';
static const qrCodeToken = 'QR Code 令牌';
static const qrHint = '粘贴二维码解析内容...';
@@ -231,8 +232,24 @@ class AppStrings {
static String musicRiskHubCooldownNotice(int remaining) =>
'登录后需冷却 $ticketCooldownSeconds 秒,剩余 $remaining 秒后可执行高危操作。';
// Best50 (Best 50 成绩图)
static const best50FeatureTitle = 'Best 50';
static const best35Label = 'BEST 35';
static const best15Label = 'BEST 15';
static const best50Empty = '没有 Rating 数据。';
static const best50LoadingMusic = '正在加载乐曲数据...';
static const best50Export = '导出图片';
static const best50Exporting = '导出中...';
static const best50NoMusicData = '乐曲数据未就绪,定数与曲名可能缺失。';
static String best50RatingSum(int b35, int b15) =>
'B35: $b35 + B15: $b15 = ${b35 + b15}';
static String best50Exported(String? path) =>
path == null ? '图片已导出' : '图片已导出到 $path';
static String best50ExportFailed(String error) => '导出失败: $error';
static const best50Footer = 'Generated by Ichino. UI designed by chuxuehaocai.';
// About
static const aboutTitle = 'Purarine';
static const aboutTitle = 'Ichino';
static const aboutDesc = '基于 QR Code 的 maimai DX 街机网络登录客户端。';
static const credits = '致谢';
static const creditBuiltWith = '构建框架';
+102
View File
@@ -0,0 +1,102 @@
/// GetUserRatingApi 的 `userRating` 结构,移植自 Empurple 的 `UserRatingData`。
///
/// 服务端已按 RA 排好 BEST 35 / BEST 15,客户端不再重排。
enum MusicLevel {
basic,
advanced,
expert,
master,
reMaster;
static MusicLevel fromInt(int level) =>
level >= 0 && level < MusicLevel.values.length
? MusicLevel.values[level]
: MusicLevel.basic;
}
class UserRatingItemBean {
final int musicId;
final int level;
final int achievement;
final int comboStatus;
final int syncStatus;
final int romVersion;
const UserRatingItemBean({
required this.musicId,
required this.level,
required this.achievement,
required this.comboStatus,
required this.syncStatus,
required this.romVersion,
});
/// 达成率百分比数值,接口原值 1001234 => 100.1234。
double get achievementPercent => achievement / 10000.0;
/// 对应 Empurple 的 `formatRatingSimple`。
String get achievementText => '${achievementPercent.toStringAsFixed(4)}%';
/// DX 谱的 musicId 会加 10000 偏移。
bool get isDx => musicId > 10000;
MusicLevel get musicLevel => MusicLevel.fromInt(level);
factory UserRatingItemBean.fromJson(Map<String, dynamic> json) {
int toInt(dynamic v) {
if (v == null) return 0;
if (v is int) return v;
if (v is num) return v.toInt();
if (v is String) return int.tryParse(v) ?? 0;
return 0;
}
return UserRatingItemBean(
musicId: toInt(json['musicId']),
level: toInt(json['level']),
achievement: toInt(json['achievement']),
comboStatus: toInt(json['comboStatus']),
syncStatus: toInt(json['syncStatus']),
romVersion: toInt(json['romVersion']),
);
}
}
class UserRatingBean {
final int userId;
final List<UserRatingItemBean> ratingList;
final List<UserRatingItemBean> newRatingList;
const UserRatingBean({
required this.userId,
required this.ratingList,
required this.newRatingList,
});
static List<UserRatingItemBean> _parseList(dynamic raw) {
if (raw is! List) return const [];
return raw
.whereType<Map<String, dynamic>>()
.map(UserRatingItemBean.fromJson)
.toList();
}
factory UserRatingBean.fromJson(Map<String, dynamic> json) {
int toInt(dynamic v) {
if (v == null) return 0;
if (v is int) return v;
if (v is num) return v.toInt();
if (v is String) return int.tryParse(v) ?? 0;
return 0;
}
final userRating =
(json['userRating'] as Map<String, dynamic>?) ?? const {};
return UserRatingBean(
userId: toInt(json['userId']),
ratingList: _parseList(userRating['ratingList']),
newRatingList: _parseList(userRating['newRatingList']),
);
}
}
+320
View File
@@ -0,0 +1,320 @@
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import '../config/strings.dart';
import '../config/title_server_config.dart';
import '../models/user_rating.dart';
import '../services/music_data_service.dart';
import '../services/png_export_service.dart';
import '../services/rating_calculator.dart';
import '../services/title_api_service.dart';
import '../widgets/best50_poster.dart';
/// Best 50 成绩图页面:负责取数与导出,海报本身在 `widgets/best50_poster.dart`。
///
/// GetUserRatingApi 只需要 userId(Cookie 是可选的,与 Empurple 的
/// `UserRatingRequest` + `MaimaiApiClient` 一致),所以 Preview API 模式、
/// 未登录游戏服务器时同样能拉出 B50,只是拿不到 FC/AP、FS 这类详细标记。
class Best50Page extends StatefulWidget {
final int userId;
final String? cookies;
final String userName;
final int iconId;
final int playerRating;
const Best50Page({
super.key,
required this.userId,
this.cookies,
required this.userName,
required this.iconId,
required this.playerRating,
});
@override
State<Best50Page> createState() => _Best50PageState();
}
class _Best50PageState extends State<Best50Page> {
final GlobalKey _posterKey = GlobalKey();
UserRatingBean? _rating;
String? _error;
bool _loading = true;
bool _exporting = false;
bool _musicReady = false;
@override
void initState() {
super.initState();
_bootstrap();
}
@override
void didUpdateWidget(Best50Page oldWidget) {
super.didUpdateWidget(oldWidget);
// 本页在 IndexedStack 里会先于 preview/登录请求被构建,cookies 到位后补一次加载。
if (oldWidget.cookies != widget.cookies &&
(_rating == null || _error != null)) {
_bootstrap();
}
}
Future<void> _bootstrap() async {
if (!TitleServerConfigHolder().isConfigured) {
_fail(AppStrings.ticketNotConfigured);
return;
}
setState(() {
_loading = true;
_error = null;
});
try {
final service = TitleApiService(
TitleServerConfigHolder().config!,
cookies: widget.cookies,
);
final rating = await service.getUserRatingTyped(widget.userId);
if (!mounted) return;
setState(() {
_rating = rating;
_loading = false;
});
// 曲名/定数缺失时仍能出图(RA 会是 0),所以不当作致命错误。
await MusicDataService.instance.load();
if (!mounted) return;
setState(() => _musicReady = true);
} on TitleApiException catch (e) {
_fail(e.message);
} catch (e) {
_fail('$e');
}
}
void _fail(String message) {
setState(() {
_loading = false;
_rating = null;
_error = message;
});
}
List<Best50CardData> _resolve(List<UserRatingItemBean> items) {
final music = MusicDataService.instance;
return items.map((item) {
final result = RatingCalculator.compute(
music.getDs(item.musicId, item.level),
item.achievementPercent,
);
return Best50CardData(
musicId: item.musicId,
level: item.musicLevel,
title: music.getTitle(item.musicId),
achievementText: item.achievementText,
comboStatus: item.comboStatus,
syncStatus: item.syncStatus,
ra: result.ra,
rate: result.rate,
);
}).toList();
}
Future<void> _export() async {
setState(() => _exporting = true);
try {
final boundary =
_posterKey.currentContext?.findRenderObject()
as RenderRepaintBoundary?;
if (boundary == null) throw Exception('海报尚未完成布局');
final image = await boundary.toImage(pixelRatio: Best50Poster.exportPixelRatio);
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
image.dispose();
if (byteData == null) throw Exception('PNG 编码失败');
final path = await exportPng(
byteData.buffer.asUint8List(),
'best50_${widget.userId}.png',
);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(AppStrings.best50Exported(path))),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(AppStrings.best50ExportFailed('$e'))),
);
} finally {
if (mounted) setState(() => _exporting = false);
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
if (_loading) {
return const Center(child: CircularProgressIndicator());
}
final rating = _rating;
if (rating == null) {
return _StateView(
message: _error ?? AppStrings.loadFailed,
onRetry: _bootstrap,
);
}
if (rating.ratingList.isEmpty && rating.newRatingList.isEmpty) {
return _StateView(
message: AppStrings.best50Empty,
onRetry: _bootstrap,
icon: Icons.bar_chart_outlined,
);
}
final best35 = _resolve(
rating.ratingList.take(Best50Poster.maxBest35).toList(),
);
final best15 = _resolve(
rating.newRatingList.take(Best50Poster.maxBest15).toList(),
);
return Scaffold(
body: Column(
children: [
if (!_musicReady)
_NoticeBanner(theme: theme, text: AppStrings.best50NoMusicData),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: _exporting ? null : _export,
icon: _exporting
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.download, size: 20),
label: Text(
_exporting
? AppStrings.best50Exporting
: AppStrings.best50Export,
),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
),
),
Expanded(
child: InteractiveViewer(
maxScale: 6,
boundaryMargin: const EdgeInsets.all(24),
// FittedBox 会按视口把固定尺寸的海报缩出来;它给子节点无限约束,
// 所以海报仍保持 1762x1850,RepaintBoundary 录到的也是原始尺寸
// (祖先变换不会被烘进边界里),导出不会糊。
child: FittedBox(
fit: BoxFit.contain,
alignment: Alignment.topCenter,
child: RepaintBoundary(
key: _posterKey,
child: Best50Poster(
userName: widget.userName,
iconId: widget.iconId,
playerRating: widget.playerRating,
best35: best35,
best15: best15,
),
),
),
),
),
],
),
);
}
}
// =============================================================================
// 页面级辅助组件(非海报部分,跟随 Ichino 现有卡片/横幅风格)
// =============================================================================
class _NoticeBanner extends StatelessWidget {
final ThemeData theme;
final String text;
const _NoticeBanner({required this.theme, required this.text});
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
margin: const EdgeInsets.fromLTRB(16, 12, 16, 0),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: theme.colorScheme.tertiaryContainer.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(10),
),
child: Text(
text,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onTertiaryContainer,
),
),
);
}
}
class _StateView extends StatelessWidget {
final String message;
final VoidCallback onRetry;
final IconData icon;
const _StateView({
required this.message,
required this.onRetry,
this.icon = Icons.error_outline,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 48, color: theme.colorScheme.error),
const SizedBox(height: 12),
Text(
message,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 20),
FilledButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh, size: 20),
label: const Text(AppStrings.retry),
),
],
),
),
);
}
}
+11 -1
View File
@@ -8,6 +8,7 @@ import '../models/user_data.dart';
import '../models/user_preview.dart';
import '../services/title_api_service.dart';
import 'about_page.dart';
import 'best50_page.dart';
import 'high_risk_feature_page.dart';
import 'settings_page.dart';
import 'ticket_page.dart';
@@ -42,6 +43,7 @@ class _HomePageState extends State<HomePage> {
static const _tabTitles = [
AppStrings.tabHome,
AppStrings.tabTickets,
AppStrings.tabBest50,
AppStrings.tabRisk,
AppStrings.tabSettings,
AppStrings.tabAbout,
@@ -218,6 +220,13 @@ class _HomePageState extends State<HomePage> {
playerRating: _userData?.playerRating ?? _preview?.playerRating ?? 0,
onLogoutRequested: _session.gameLogin != null ? _logoutSession : null,
),
Best50Page(
userId: widget.userId,
cookies: _session.cookies,
userName: _userData?.userName ?? _preview?.userName ?? '',
iconId: _userData?.iconId ?? _preview?.iconId ?? 0,
playerRating: _userData?.playerRating ?? _preview?.playerRating ?? 0,
),
HighRiskFeaturePage(
userId: widget.userId,
cookies: _session.cookies,
@@ -237,6 +246,7 @@ class _HomePageState extends State<HomePage> {
destinations: const [
NavigationDestination(icon: Icon(Icons.home_outlined), selectedIcon: Icon(Icons.home), label: AppStrings.tabHome),
NavigationDestination(icon: Icon(Icons.confirmation_number_outlined), selectedIcon: Icon(Icons.confirmation_number), label: AppStrings.tabTickets),
NavigationDestination(icon: Icon(Icons.bar_chart_outlined), selectedIcon: Icon(Icons.bar_chart), label: AppStrings.tabBest50),
NavigationDestination(icon: Icon(Icons.warning_outlined), selectedIcon: Icon(Icons.inventory_2), label: AppStrings.tabRisk),
NavigationDestination(icon: Icon(Icons.settings_outlined), selectedIcon: Icon(Icons.settings), label: AppStrings.tabSettings),
NavigationDestination(icon: Icon(Icons.info_outlined), selectedIcon: Icon(Icons.info), label: AppStrings.tabAbout),
@@ -272,7 +282,7 @@ class _HomePageState extends State<HomePage> {
),
const SizedBox(height: 24),
FilledButton.icon(
onPressed: () => setState(() => _currentTab = 3),
onPressed: () => setState(() => _currentTab = 4),
icon: const Icon(Icons.settings, size: 20),
label: const Text(AppStrings.openSettings),
),
+8
View File
@@ -0,0 +1,8 @@
import 'music_data_cache_native.dart'
if (dart.library.html) 'music_data_cache_web.dart' as impl;
/// 乐曲元数据的磁盘缓存,Web 平台为空实现。读失败返回 null,写失败静默忽略。
Future<String?> readTextCache(String fileName) => impl.readTextCache(fileName);
Future<void> writeTextCache(String fileName, String content) =>
impl.writeTextCache(fileName, content);
+29
View File
@@ -0,0 +1,29 @@
import 'dart:io';
/// 乐曲元数据是可重新下载的参考数据,放系统临时目录即可,
/// 不引入 path_provider(它经 path_provider_android 拖进 jni 插件,
/// jni 会在 Windows 上强行链接 jvm.lib)。
Future<String?> readTextCache(String fileName) async {
try {
final file = await _file(fileName);
if (!await file.exists()) return null;
final content = await file.readAsString();
return content.isEmpty ? null : content;
} catch (_) {
return null;
}
}
Future<void> writeTextCache(String fileName, String content) async {
try {
await (await _file(fileName)).writeAsString(content);
} catch (_) {
// 缓存是尽力而为,失败只影响下次启动的加载速度。
}
}
Future<File> _file(String fileName) async {
final dir = Directory('${Directory.systemTemp.path}/ichino_cache');
await dir.create(recursive: true);
return File('${dir.path}/$fileName');
}
+3
View File
@@ -0,0 +1,3 @@
Future<String?> readTextCache(String fileName) async => null;
Future<void> writeTextCache(String fileName, String content) async {}
+93
View File
@@ -0,0 +1,93 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'music_data_cache.dart';
class MusicInfo {
final String title;
final Map<int, double> dsByLevel;
const MusicInfo({required this.title, required this.dsByLevel});
}
/// 乐曲元数据来源,移植自 Empurple 的 `MusicDataProvider`。
///
/// 游戏接口的 ratingList 只有 musicId,曲名与各难度定数需要另行获取。
class MusicDataService {
static const String _musicDataUrl =
'https://www.diving-fish.com/api/maimaidxprober/music_data';
static const String _cacheFileName = 'music_data.json';
MusicDataService._();
static final MusicDataService instance = MusicDataService._();
final Map<int, MusicInfo> _byId = {};
Future<void>? _inFlight;
String getTitle(int musicId) => _byId[musicId]?.title ?? 'Unknown';
/// 缺失定数时返回 0,RA 也随之为 0,与 Empurple 行为一致。
double getDs(int musicId, int level) =>
_byId[musicId]?.dsByLevel[level] ?? 0.0;
/// 并发调用共享同一次下载。
Future<void> load() {
if (_byId.isNotEmpty) return Future.value();
return _inFlight ??= _load().whenComplete(() => _inFlight = null);
}
Future<void> _load() async {
final cached = await readTextCache(_cacheFileName);
final content = cached ?? await _download();
_parse(content);
if (cached == null) {
await writeTextCache(_cacheFileName, content);
}
}
Future<String> _download() async {
final response = await http
.get(Uri.parse(_musicDataUrl), headers: const {'User-Agent': 'Ichino/1.0'})
.timeout(const Duration(seconds: 20));
if (response.statusCode != 200) {
throw Exception('music_data HTTP ${response.statusCode}');
}
return response.body;
}
void _parse(String content) {
final decoded = jsonDecode(content);
if (decoded is! List) return;
_byId.clear();
for (final item in decoded.whereType<Map<String, dynamic>>()) {
final id = _toInt(item['id']);
if (id == 0) continue;
final ds = item['ds'];
_byId[id] = MusicInfo(
title: item['title']?.toString() ?? 'Unknown',
dsByLevel: ds is List
? {
for (var level = 0; level < ds.length; level++)
level: _toDouble(ds[level]),
}
: const {},
);
}
}
static int _toInt(dynamic v) {
if (v is int) return v;
if (v is num) return v.toInt();
if (v is String) return int.tryParse(v) ?? 0;
return 0;
}
static double _toDouble(dynamic v) {
if (v is double) return v;
if (v is int) return v.toDouble();
if (v is String) return double.tryParse(v) ?? 0.0;
return 0.0;
}
}
+10
View File
@@ -0,0 +1,10 @@
import 'dart:typed_data';
import 'png_export_service_native.dart'
if (dart.library.html) 'png_export_service_web.dart' as impl;
/// 导出一张 PNG:原生平台落临时盘并拉起系统分享,Web 走下载或 Web Share API。
///
/// 返回原生平台上的落盘路径(用于分享能力缺失时提示用户),Web 返回 null。
Future<String?> exportPng(Uint8List bytes, String fileName) =>
impl.exportPng(bytes, fileName);
@@ -0,0 +1,22 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:share_plus/share_plus.dart';
Future<String?> exportPng(Uint8List bytes, String fileName) async {
final file = File('${Directory.systemTemp.path}/$fileName');
await file.writeAsBytes(bytes);
try {
await SharePlus.instance.share(
ShareParams(
files: [XFile.fromData(bytes, name: fileName, mimeType: 'image/png')],
fileNameOverrides: [fileName],
),
);
} on UnimplementedError {
// Linux 与 Windows 10 RS5 之前不支持分享文件,文件已落盘,返回路径即可。
}
return file.path;
}
+15
View File
@@ -0,0 +1,15 @@
import 'dart:typed_data';
import 'package:share_plus/share_plus.dart';
Future<String?> exportPng(Uint8List bytes, String fileName) async {
await SharePlus.instance.share(
ShareParams(
files: [
XFile.fromData(bytes, name: fileName, mimeType: 'image/png'),
],
fileNameOverrides: [fileName],
),
);
return null;
}
+1 -1
View File
@@ -1,6 +1,6 @@
import 'package:flutter/services.dart';
const _channel = MethodChannel('dev.naominet.purarine/qr_scanner');
const _channel = MethodChannel('dev.naominet.ichino/qr_scanner');
Future<String?> decodeQRFromBytes(Uint8List bytes) async {
try {
+65
View File
@@ -0,0 +1,65 @@
/// RA / 评级计算,移植自 Empurple 的 `RatingCalculator`。
///
/// `achievement` 传入百分比数值(接口原值 1001234 => 100.1234),`ds` 为谱面定数。
class RaResult {
final int ra;
final String rate;
const RaResult(this.ra, this.rate);
}
/// 达成率区间上界 / RA 系数 / 评级,顺序与 Empurple 的 when 分支一致。
const List<(int, int, String)> _bands = [
(100000, 0, 'D'),
(200000, 16, 'D'),
(300000, 32, 'D'),
(400000, 48, 'D'),
(500000, 64, 'D'),
(600000, 80, 'C'),
(700000, 96, 'B'),
(750000, 112, 'BB'),
(799999, 120, 'BBB'),
(800000, 128, 'BBB'),
(900000, 136, 'A'),
(940000, 152, 'AA'),
(969999, 168, 'AAA'),
(970000, 176, 'AAA'),
(980000, 200, 'S'),
(989999, 203, 'Sp'),
(990000, 206, 'Sp'),
(995000, 208, 'SS'),
(999999, 211, 'SSp'),
(1000000, 214, 'SSp'),
(1004999, 216, 'SSS'),
(1005000, 222, 'SSS'),
];
const int _maxAchievement = 1005000;
const int _ssspOffset = 224;
class RatingCalculator {
RatingCalculator._();
static int computeRa(double ds, double achievement) =>
compute(ds, achievement).ra;
static String computeRaRate(double ds, double achievement) =>
compute(ds, achievement).rate;
static RaResult compute(double ds, double achievement) {
final achievementRaw =
(achievement * 10000.0).round().clamp(0, _maxAchievement);
var offset = _ssspOffset;
var rate = 'SSSp';
for (final (upperBound, bandOffset, bandRate) in _bands) {
if (achievementRaw < upperBound) {
offset = bandOffset;
rate = bandRate;
break;
}
}
final scoreRate = (ds * 10.0).round();
final ra = scoreRate * achievementRaw * offset ~/ 100000000;
return RaResult(ra, rate);
}
}
+6
View File
@@ -10,6 +10,7 @@ import 'package:pointycastle/export.dart';
import '../config/title_server_config.dart';
import '../models/user_data.dart';
import '../models/user_preview.dart';
import '../models/user_rating.dart';
class TitleApiException implements Exception {
final String message;
@@ -354,6 +355,11 @@ class TitleApiService {
return _callApi('GetUserRatingApi', {'userId': userId}, userId);
}
Future<UserRatingBean> getUserRatingTyped(int userId) async {
final json = await getUserRating(userId);
return UserRatingBean.fromJson(json);
}
Future<Map<String, dynamic>> getUserCharge(int userId) async {
return _callApi('GetUserChargeApi', {'userId': userId}, userId);
}
+350
View File
@@ -0,0 +1,350 @@
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),
};
}