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。
No files matched your search
@@ -5,14 +5,14 @@
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Purarine",
|
||||
"name": "Ichino",
|
||||
"request": "launch",
|
||||
"type": "dart",
|
||||
"program": "lib/main.dart",
|
||||
"deviceId": "windows"
|
||||
},
|
||||
{
|
||||
"name": "Purarine (profile mode)",
|
||||
"name": "Ichino (profile mode)",
|
||||
"request": "launch",
|
||||
"type": "dart",
|
||||
"program": "lib/main.dart",
|
||||
@@ -20,7 +20,7 @@
|
||||
"flutterMode": "profile"
|
||||
},
|
||||
{
|
||||
"name": "Purarine (release mode)",
|
||||
"name": "Ichino (release mode)",
|
||||
"request": "launch",
|
||||
"type": "dart",
|
||||
"program": "lib/main.dart",
|
||||
|
||||
@@ -5,7 +5,7 @@ plugins {
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "dev.naominet.purarine"
|
||||
namespace = "dev.naominet.ichino"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
@@ -15,7 +15,7 @@ android {
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "dev.naominet.purarine"
|
||||
applicationId = "dev.naominet.ichino"
|
||||
minSdk = flutter.minSdkVersion
|
||||
targetSdk = flutter.targetSdkVersion
|
||||
versionCode = flutter.versionCode
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<application
|
||||
android:label="Purarine"
|
||||
android:label="Ichino"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:usesCleartextTraffic="true">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package dev.naominet.purarine
|
||||
package dev.naominet.ichino
|
||||
|
||||
import android.graphics.BitmapFactory
|
||||
import com.google.mlkit.vision.barcode.BarcodeScanning
|
||||
@@ -9,7 +9,7 @@ import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
|
||||
class MainActivity : FlutterActivity() {
|
||||
private val CHANNEL = "dev.naominet.purarine/qr_scanner"
|
||||
private val CHANNEL = "dev.naominet.ichino/qr_scanner"
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 923 KiB |
@@ -475,7 +475,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = dev.naominet.purarine;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = dev.naominet.ichino;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
@@ -493,7 +493,7 @@
|
||||
DEVELOPMENT_TEAM = 3ACWHAQMGP;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = dev.naominet.purarine.RunnerTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = dev.naominet.ichino.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
@@ -512,7 +512,7 @@
|
||||
DEVELOPMENT_TEAM = 3ACWHAQMGP;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = dev.naominet.purarine.RunnerTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = dev.naominet.ichino.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||
@@ -529,7 +529,7 @@
|
||||
DEVELOPMENT_TEAM = 3ACWHAQMGP;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = dev.naominet.purarine.RunnerTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = dev.naominet.ichino.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||
@@ -663,7 +663,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = dev.naominet.purarine;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = dev.naominet.ichino;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
@@ -688,7 +688,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = dev.naominet.purarine;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = dev.naominet.ichino;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Purarine</string>
|
||||
<string>Ichino</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
@@ -15,7 +15,7 @@
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>purarine</string>
|
||||
<string>ichino</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
|
||||
@@ -14,7 +14,7 @@ class SceneDelegate: FlutterSceneDelegate {
|
||||
guard let controller = window?.rootViewController as? FlutterViewController else { return }
|
||||
|
||||
let channel = FlutterMethodChannel(
|
||||
name: "dev.naominet.purarine/qr_scanner",
|
||||
name: "dev.naominet.ichino/qr_scanner",
|
||||
binaryMessenger: controller.binaryMessenger
|
||||
)
|
||||
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -4,10 +4,10 @@ project(runner LANGUAGES CXX)
|
||||
|
||||
# The name of the executable created for the application. Change this to change
|
||||
# the on-disk name of your application.
|
||||
set(BINARY_NAME "purarine")
|
||||
set(BINARY_NAME "ichino")
|
||||
# The unique GTK application identifier for this application. See:
|
||||
# https://wiki.gnome.org/HowDoI/ChooseApplicationID
|
||||
set(APPLICATION_ID "dev.naominet.purarine")
|
||||
set(APPLICATION_ID "dev.naominet.ichino")
|
||||
|
||||
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
|
||||
# versions of CMake.
|
||||
|
||||
@@ -7,9 +7,13 @@
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <file_selector_linux/file_selector_plugin.h>
|
||||
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||
|
||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
|
||||
file_selector_plugin_register_with_registrar(file_selector_linux_registrar);
|
||||
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
|
||||
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
file_selector_linux
|
||||
url_launcher_linux
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
|
||||
@@ -45,11 +45,11 @@ static void my_application_activate(GApplication* application) {
|
||||
if (use_header_bar) {
|
||||
GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new());
|
||||
gtk_widget_show(GTK_WIDGET(header_bar));
|
||||
gtk_header_bar_set_title(header_bar, "Purarine");
|
||||
gtk_header_bar_set_title(header_bar, "Ichino");
|
||||
gtk_header_bar_set_show_close_button(header_bar, TRUE);
|
||||
gtk_window_set_titlebar(window, GTK_WIDGET(header_bar));
|
||||
} else {
|
||||
gtk_window_set_title(window, "Purarine");
|
||||
gtk_window_set_title(window, "Ichino");
|
||||
}
|
||||
|
||||
gtk_window_set_default_size(window, 1280, 720);
|
||||
|
||||
@@ -6,9 +6,11 @@ import FlutterMacOS
|
||||
import Foundation
|
||||
|
||||
import file_selector_macos
|
||||
import share_plus
|
||||
import shared_preferences_foundation
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
||||
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
}
|
||||
@@ -65,7 +65,7 @@
|
||||
331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
|
||||
333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = "<group>"; };
|
||||
335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = "<group>"; };
|
||||
33CC10ED2044A3C60003C045 /* Purarine.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Purarine.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
33CC10ED2044A3C60003C045 /* Ichino.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Ichino.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = "<group>"; };
|
||||
33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = "<group>"; };
|
||||
@@ -134,7 +134,7 @@
|
||||
33CC10EE2044A3C60003C045 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
33CC10ED2044A3C60003C045 /* Purarine.app */,
|
||||
33CC10ED2044A3C60003C045 /* Ichino.app */,
|
||||
331C80D5294CF71000263BE5 /* RunnerTests.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
@@ -224,7 +224,7 @@
|
||||
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
|
||||
);
|
||||
productName = Runner;
|
||||
productReference = 33CC10ED2044A3C60003C045 /* Purarine.app */;
|
||||
productReference = 33CC10ED2044A3C60003C045 /* Ichino.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
@@ -395,10 +395,10 @@
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = dev.naominet.purarine.RunnerTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = dev.naominet.ichino.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Purarine.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Purarine";
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Ichino.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Ichino";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
@@ -409,10 +409,10 @@
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = dev.naominet.purarine.RunnerTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = dev.naominet.ichino.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Purarine.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Purarine";
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Ichino.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Ichino";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
@@ -423,10 +423,10 @@
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = dev.naominet.purarine.RunnerTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = dev.naominet.ichino.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Purarine.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Purarine";
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Ichino.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Ichino";
|
||||
};
|
||||
name = Profile;
|
||||
};
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "33CC10EC2044A3C60003C045"
|
||||
BuildableName = "Purarine.app"
|
||||
BuildableName = "Ichino.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
@@ -33,7 +33,7 @@
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "33CC10EC2044A3C60003C045"
|
||||
BuildableName = "Purarine.app"
|
||||
BuildableName = "Ichino.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
@@ -49,7 +49,7 @@
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "33CC10EC2044A3C60003C045"
|
||||
BuildableName = "Purarine.app"
|
||||
BuildableName = "Ichino.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
@@ -84,7 +84,7 @@
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "33CC10EC2044A3C60003C045"
|
||||
BuildableName = "Purarine.app"
|
||||
BuildableName = "Ichino.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
@@ -101,7 +101,7 @@
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "33CC10EC2044A3C60003C045"
|
||||
BuildableName = "Purarine.app"
|
||||
BuildableName = "Ichino.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
// 'flutter create' template.
|
||||
|
||||
// The application's name. By default this is also the title of the Flutter window.
|
||||
PRODUCT_NAME = Purarine
|
||||
PRODUCT_NAME = Ichino
|
||||
|
||||
// The application's bundle identifier
|
||||
PRODUCT_BUNDLE_IDENTIFIER = dev.naominet.purarine
|
||||
PRODUCT_BUNDLE_IDENTIFIER = dev.naominet.ichino
|
||||
|
||||
// The copyright displayed in application information
|
||||
PRODUCT_COPYRIGHT = Copyright © 2026 dev.naominet. All rights reserved.
|
||||
@@ -57,6 +57,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
code_assets:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: code_assets
|
||||
sha256: "828110d598123b5ea96c00c9f3c72105bf79f8ee36c20a39b26209ade421ec57"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.0"
|
||||
collection:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -113,6 +121,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
ffi_leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: ffi_leak_tracker
|
||||
sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.2"
|
||||
file:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -153,6 +169,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.3+5"
|
||||
fixnum:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: fixnum
|
||||
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
@@ -184,6 +208,14 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
hooks:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: hooks
|
||||
sha256: eaac480a35ec0814146c2c48d96aaa829e0e44a7662c88ae84c9edf4bc35651f
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
http:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -304,6 +336,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.0"
|
||||
logging:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: logging
|
||||
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
matcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -336,6 +376,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
objective_c:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: objective_c
|
||||
sha256: ad56fd53a78ff6b1472fa59ff2a4e8b8ccabafc586fc263a1dfad0b99b5553e3
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.6.0"
|
||||
path:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -344,6 +392,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
path_provider:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider
|
||||
sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.6"
|
||||
path_provider_android:
|
||||
dependency: "direct overridden"
|
||||
description:
|
||||
name: path_provider_android
|
||||
sha256: "149441ca6e4f38193b2e004c0ca6376a3d11f51fa5a77552d8bd4d2b0c0912ba"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.23"
|
||||
path_provider_foundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_foundation
|
||||
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.0"
|
||||
path_provider_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -400,6 +472,38 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.5.0"
|
||||
pub_semver:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pub_semver
|
||||
sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.1"
|
||||
record_use:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: record_use
|
||||
sha256: "1cb8564af8d43b464294411db9217f5ec04891c6f22ee2c32d73ae05e88a6bd2"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
share_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: share_plus
|
||||
sha256: "34f00f9becd2743c1fb05363d624f9f70d37f7ccdcdda47450bc0b8c9d327b8c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "13.3.0"
|
||||
share_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: share_plus_platform_interface
|
||||
sha256: "365ef7379fc22507256adda3385152942ffce08935452bc972c2e52a0bebae41"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.2.0"
|
||||
shared_preferences:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -517,6 +621,46 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
url_launcher_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_linux
|
||||
sha256: "10f86fef4c2c43563fa6c211ff9cf757adf4d3ab762c56bd430664a947d70cd0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.3"
|
||||
url_launcher_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_platform_interface
|
||||
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.2"
|
||||
url_launcher_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_web
|
||||
sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.3"
|
||||
url_launcher_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_windows
|
||||
sha256: "6c5ad3f22cd4c38e089b81963b3cd7bb83b111b2df5dce008bb066162f42e429"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.6"
|
||||
uuid:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: uuid
|
||||
sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.6.0"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -541,6 +685,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
win32:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: win32
|
||||
sha256: a0b93865d5644f11cf6a8c3f6db909f1ec168958b5805f6cc684adea957cd63d
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.4.0"
|
||||
xdg_directories:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -549,6 +701,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: yaml
|
||||
sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.4"
|
||||
sdks:
|
||||
dart: ">=3.12.0 <4.0.0"
|
||||
flutter: ">=3.38.0"
|
||||
flutter: ">=3.38.4"
|
||||
@@ -1,4 +1,4 @@
|
||||
name: purarine
|
||||
name: ichino
|
||||
description: "A new Flutter project."
|
||||
# The following line prevents the package from being accidentally published to
|
||||
# pub.dev using `flutter pub publish`. This is preferred for private packages.
|
||||
@@ -38,6 +38,14 @@ dependencies:
|
||||
archive: ^4.0.9
|
||||
pointycastle: ^3.9.1
|
||||
shared_preferences: ^2.5.5
|
||||
share_plus: ^13.3.0
|
||||
|
||||
# path_provider_android 2.3.x 开始依赖 jni,而 jni 把 windows/linux 也声明成了
|
||||
# ffiPlugin,只要它出现在依赖树里,桌面构建就会去链接 jvm.lib(本机只有
|
||||
# ARM64 JDK,x64 构建必然 LNK2019)。本应用不使用 path_provider,
|
||||
# 它只是 share_plus 的间接依赖,因此把 Android 实现钉在引入 jni 之前。
|
||||
dependency_overrides:
|
||||
path_provider_android: <2.3.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
@@ -56,10 +64,8 @@ flutter:
|
||||
# the material Icons class.
|
||||
uses-material-design: true
|
||||
|
||||
# To add assets to your application, add an assets section, like this:
|
||||
# assets:
|
||||
# - images/a_dot_burr.jpeg
|
||||
# - images/a_dot_ham.jpeg
|
||||
assets:
|
||||
- assets/b50/
|
||||
|
||||
# An image asset can refer to one or more resolution-specific "variants", see
|
||||
# https://flutter.dev/to/resolution-aware-images
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ichino/config/strings.dart';
|
||||
import 'package:ichino/models/user_rating.dart';
|
||||
import 'package:ichino/pages/best50_page.dart';
|
||||
import 'package:ichino/widgets/best50_poster.dart';
|
||||
|
||||
List<Best50CardData> _cards(int count, {String tag = 'a'}) => [
|
||||
for (var i = 0; i < count; i++)
|
||||
Best50CardData(
|
||||
musicId: 1000 + i,
|
||||
level: MusicLevel.values[i % MusicLevel.values.length],
|
||||
title: '$tag#$i',
|
||||
achievementText: '100.${(1000 + i).toString().padLeft(4, '0')}%',
|
||||
comboStatus: i % 5,
|
||||
syncStatus: i % 5,
|
||||
ra: 50 + i,
|
||||
rate: const [
|
||||
'D', 'C', 'B', 'BB', 'BBB', 'A', 'AA',
|
||||
'AAA', 'S', 'Sp', 'SS', 'SSp', 'SSS', 'SSSp',
|
||||
][i % 14],
|
||||
),
|
||||
];
|
||||
|
||||
Future<void> _pumpPoster(
|
||||
WidgetTester tester, {
|
||||
required List<Best50CardData> best35,
|
||||
required List<Best50CardData> best15,
|
||||
}) async {
|
||||
tester.view.physicalSize = const Size(1800, 1900);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.reset);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Best50Poster(
|
||||
userName: 'Test Player',
|
||||
iconId: 0,
|
||||
playerRating: 12345,
|
||||
best35: best35,
|
||||
best15: best15,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('卡片栅格坐标(对齐 Empurple)', () {
|
||||
test('每行 5 张,列距 320、首列 65', () {
|
||||
expect(Best50Poster.cardLeft(0), 65);
|
||||
expect(Best50Poster.cardLeft(4), 65 + 320 * 4);
|
||||
expect(Best50Poster.cardLeft(5), 65);
|
||||
});
|
||||
|
||||
test('BEST35 区块固定从 350 起,每行下移 120', () {
|
||||
expect(Best50Poster.cardTopBest35(0), 350);
|
||||
expect(Best50Poster.cardTopBest35(4), 350);
|
||||
expect(Best50Poster.cardTopBest35(5), 350 + 120);
|
||||
expect(Best50Poster.cardTopBest35(34), 350 + 120 * 6);
|
||||
});
|
||||
|
||||
test('BEST15 区块固定从 1250 起,与 BEST35 有多少条无关', () {
|
||||
expect(Best50Poster.cardTopBest15(0), 1250);
|
||||
expect(Best50Poster.cardTopBest15(5), 1250 + 120);
|
||||
expect(Best50Poster.cardTopBest15(14), 1250 + 120 * 2);
|
||||
});
|
||||
|
||||
test('满 50 张时最后一张不越出底图', () {
|
||||
final right =
|
||||
Best50Poster.cardLeft(Best50Poster.columnsPerRow - 1) +
|
||||
Best50Poster.cardWidth;
|
||||
final bottom =
|
||||
Best50Poster.cardTopBest15(Best50Poster.maxBest15 - 1) +
|
||||
Best50Poster.cardHeight;
|
||||
expect(right, lessThanOrEqualTo(Best50Poster.canvasWidth));
|
||||
expect(bottom, lessThanOrEqualTo(Best50Poster.canvasHeight));
|
||||
});
|
||||
});
|
||||
|
||||
group('Best50Poster 渲染', () {
|
||||
testWidgets('满员 35+15 按底图尺寸渲染且无溢出', (tester) async {
|
||||
await _pumpPoster(tester, best35: _cards(35), best15: _cards(15));
|
||||
|
||||
expect(tester.takeException(), isNull);
|
||||
final size = tester.getSize(find.byType(Best50Poster));
|
||||
expect(size.width, Best50Poster.canvasWidth);
|
||||
expect(size.height, Best50Poster.canvasHeight);
|
||||
});
|
||||
|
||||
testWidgets('条目不足时不报错', (tester) async {
|
||||
await _pumpPoster(tester, best35: _cards(3), best15: const []);
|
||||
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('BEST35 未打满时 BEST15 仍固定在第二区块', (tester) async {
|
||||
// 回归用例:以前按 35+15 拼接后的全局序号算行,BEST35 不满 35 条时
|
||||
// BEST15 会整体上移、混进上半区。
|
||||
await _pumpPoster(tester, best35: _cards(3), best15: _cards(5, tag: 'b'));
|
||||
|
||||
expect(tester.takeException(), isNull);
|
||||
final firstOf35 = tester.getTopLeft(find.text('a#0')).dy;
|
||||
final lastOf35 = tester.getTopLeft(find.text('a#2')).dy;
|
||||
final firstOf15 = tester.getTopLeft(find.text('b#0')).dy;
|
||||
final lastOf15 = tester.getTopLeft(find.text('b#4')).dy;
|
||||
|
||||
expect(firstOf35, greaterThanOrEqualTo(Best50Poster.gridTopFirstBlock));
|
||||
expect(lastOf35, lessThan(Best50Poster.gridTopSecondBlock));
|
||||
expect(firstOf15, greaterThanOrEqualTo(Best50Poster.gridTopSecondBlock));
|
||||
expect(lastOf15, greaterThanOrEqualTo(Best50Poster.gridTopSecondBlock));
|
||||
});
|
||||
|
||||
testWidgets('窄视口里海报仍保持底图尺寸(FittedBox 预览)', (tester) async {
|
||||
// 回归用例:InteractiveViewer 的 constrained 默认为 true,直接把固定尺寸的
|
||||
// 海报当子节点会被压成视口大小——底图压扁、Positioned 的卡片掉出画布被裁掉。
|
||||
// 中间垫一层 FittedBox 才是对的:视口拿到的是缩放后的显示,边界录到的仍是原尺寸。
|
||||
tester.view.physicalSize = const Size(400, 900);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.reset);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: InteractiveViewer(
|
||||
child: FittedBox(
|
||||
fit: BoxFit.contain,
|
||||
child: Best50Poster(
|
||||
userName: 'Test Player',
|
||||
iconId: 0,
|
||||
playerRating: 12345,
|
||||
best35: _cards(35),
|
||||
best15: _cards(15, tag: 'b'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(tester.takeException(), isNull);
|
||||
expect(
|
||||
tester.getSize(find.byType(Best50Poster)),
|
||||
const Size(
|
||||
Best50Poster.canvasWidth,
|
||||
Best50Poster.canvasHeight,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('超出上限的条目被裁剪到 35+15', (tester) async {
|
||||
await _pumpPoster(tester, best35: _cards(60), best15: _cards(40, tag: 'b'));
|
||||
|
||||
expect(tester.takeException(), isNull);
|
||||
// BEST35 只保留前 35 条,BEST15 只保留前 15 条。
|
||||
expect(find.text('a#34'), findsOneWidget);
|
||||
expect(find.text('a#35'), findsNothing);
|
||||
expect(find.text('b#14'), findsOneWidget);
|
||||
expect(find.text('b#15'), findsNothing);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('Best50Page 在未配置 Title Server 时给出提示', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Best50Page(
|
||||
userId: 1,
|
||||
userName: 'Nobody',
|
||||
iconId: 0,
|
||||
playerRating: 0,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text(AppStrings.ticketNotConfigured), findsOneWidget);
|
||||
expect(find.byIcon(Icons.refresh), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ichino/services/rating_calculator.dart';
|
||||
|
||||
/// achievementRaw 接口原值(百分比 * 10000)。
|
||||
double _pct(int raw) => raw / 10000.0;
|
||||
|
||||
void main() {
|
||||
group('评级区间边界', () {
|
||||
const cases = <int, String>{
|
||||
0: 'D',
|
||||
99999: 'D',
|
||||
100000: 'D',
|
||||
499999: 'D',
|
||||
500000: 'C',
|
||||
599999: 'C',
|
||||
600000: 'B',
|
||||
699999: 'B',
|
||||
700000: 'BB',
|
||||
749999: 'BB',
|
||||
750000: 'BBB',
|
||||
799999: 'BBB',
|
||||
800000: 'A',
|
||||
899999: 'A',
|
||||
900000: 'AA',
|
||||
939999: 'AA',
|
||||
940000: 'AAA',
|
||||
969999: 'AAA',
|
||||
970000: 'S',
|
||||
979999: 'S',
|
||||
980000: 'Sp',
|
||||
989999: 'Sp',
|
||||
990000: 'SS',
|
||||
994999: 'SS',
|
||||
995000: 'SSp',
|
||||
999999: 'SSp',
|
||||
1000000: 'SSS',
|
||||
1004999: 'SSS',
|
||||
1005000: 'SSSp',
|
||||
};
|
||||
|
||||
cases.forEach((raw, expected) {
|
||||
test('$raw => $expected', () {
|
||||
expect(RatingCalculator.compute(10.0, _pct(raw)).rate, expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
group('RA 计算', () {
|
||||
test('低于 10% 时 RA 为 0', () {
|
||||
expect(RatingCalculator.computeRa(10.0, _pct(99999)), 0);
|
||||
});
|
||||
|
||||
test('ds 10.0 / 100.1234% => 216', () {
|
||||
expect(RatingCalculator.compute(10.0, _pct(1001234)).ra, 216);
|
||||
});
|
||||
|
||||
test('ds 10.0 / 99.0000% => 205 (整数截断)', () {
|
||||
expect(RatingCalculator.compute(10.0, _pct(990000)).ra, 205);
|
||||
});
|
||||
|
||||
test('ds 10.0 / 100.5% 封顶 SSSp => 225', () {
|
||||
expect(RatingCalculator.compute(10.0, _pct(1005000)).ra, 225);
|
||||
});
|
||||
|
||||
test('超出 100.5% 的达成率被夹到上限', () {
|
||||
final clamped = RatingCalculator.compute(10.0, _pct(1200000));
|
||||
final capped = RatingCalculator.compute(10.0, _pct(1005000));
|
||||
expect(clamped.ra, capped.ra);
|
||||
expect(clamped.rate, capped.rate);
|
||||
});
|
||||
|
||||
test('定数 13.0 / 100% => 280', () {
|
||||
expect(RatingCalculator.compute(13.0, _pct(1000000)).ra, 280);
|
||||
});
|
||||
|
||||
test('缺失定数 (ds=0) 时 RA 为 0', () {
|
||||
expect(RatingCalculator.computeRa(0.0, _pct(1005000)), 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:purarine/main.dart';
|
||||
import 'package:ichino/main.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('App renders login page with app title', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(const MyApp());
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Purarine'), findsWidgets);
|
||||
expect(find.text('Ichino'), findsWidgets);
|
||||
expect(find.byType(MaterialApp), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -5,14 +5,14 @@
|
||||
|
||||
<meta charset="UTF-8">
|
||||
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
|
||||
<meta name="description" content="Purarine - maimaidx login client">
|
||||
<meta name="description" content="Ichino - maimaidx login client">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||
<meta name="apple-mobile-web-app-title" content="Purarine">
|
||||
<meta name="apple-mobile-web-app-title" content="Ichino">
|
||||
<link rel="apple-touch-icon" href="icons/Icon-192.png">
|
||||
<link rel="icon" type="image/png" href="favicon.png"/>
|
||||
|
||||
<title>Purarine</title>
|
||||
<title>Ichino</title>
|
||||
<link rel="manifest" href="manifest.json">
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/jsQR.js"></script>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "Purarine",
|
||||
"short_name": "Purarine",
|
||||
"name": "Ichino",
|
||||
"short_name": "Ichino",
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"background_color": "#0175C2",
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# Project-level configuration.
|
||||
cmake_minimum_required(VERSION 3.14)
|
||||
project(purarine LANGUAGES CXX)
|
||||
project(ichino LANGUAGES CXX)
|
||||
|
||||
# The name of the executable created for the application. Change this to change
|
||||
# the on-disk name of your application.
|
||||
set(BINARY_NAME "purarine")
|
||||
set(BINARY_NAME "ichino")
|
||||
|
||||
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
|
||||
# versions of CMake.
|
||||
|
||||
@@ -7,8 +7,14 @@
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <file_selector_windows/file_selector_windows.h>
|
||||
#include <share_plus/share_plus_windows_plugin_c_api.h>
|
||||
#include <url_launcher_windows/url_launcher_windows.h>
|
||||
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
FileSelectorWindowsRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FileSelectorWindows"));
|
||||
SharePlusWindowsPluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi"));
|
||||
UrlLauncherWindowsRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
|
||||
}
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
file_selector_windows
|
||||
share_plus
|
||||
url_launcher_windows
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
|
||||
@@ -90,12 +90,12 @@ BEGIN
|
||||
BLOCK "040904e4"
|
||||
BEGIN
|
||||
VALUE "CompanyName", "dev.naominet" "\0"
|
||||
VALUE "FileDescription", "Purarine" "\0"
|
||||
VALUE "FileDescription", "Ichino" "\0"
|
||||
VALUE "FileVersion", VERSION_AS_STRING "\0"
|
||||
VALUE "InternalName", "purarine" "\0"
|
||||
VALUE "InternalName", "ichino" "\0"
|
||||
VALUE "LegalCopyright", "Copyright (C) 2026 dev.naominet. All rights reserved." "\0"
|
||||
VALUE "OriginalFilename", "purarine.exe" "\0"
|
||||
VALUE "ProductName", "Purarine" "\0"
|
||||
VALUE "OriginalFilename", "ichino.exe" "\0"
|
||||
VALUE "ProductName", "Ichino" "\0"
|
||||
VALUE "ProductVersion", VERSION_AS_STRING "\0"
|
||||
END
|
||||
END
|
||||
|
||||
@@ -27,7 +27,7 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev,
|
||||
FlutterWindow window(project);
|
||||
Win32Window::Point origin(10, 10);
|
||||
Win32Window::Size size(1280, 720);
|
||||
if (! window.Create(L"Purarine", origin, size)) {
|
||||
if (! window.Create(L"Ichino", origin, size)) {
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
window.SetQuitOnClose(true);
|
||||
|
||||