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:
1 parent
24ccd628d1
commit
79fa0d7a20
67 files changed
+1563
-62
No files matched your search
+19
-2
@@ -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 = '构建框架';
|
||||
|
||||
@@ -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']),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
@@ -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');
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
Future<String?> readTextCache(String fileName) async => null;
|
||||
|
||||
Future<void> writeTextCache(String fileName, String content) async {}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user