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

海报从 Empurple 的 Best50ImageRenderer/ImageBuilder 移植:按原始像素坐标
(1762x1850 画布、300x100 卡片、5 列、行距 120) 叠加在 base.png 上,底图里
已画好的面板/药丸标签/页脚不重复绘制;页面外壳走 Ichino 的 Material 3。

RatingCalculator 的达成率分档改成单张有序表,避免偏移与评级错位;
RA 与评级由 test/rating_calculator_test.dart 的边界用例锁定。

GetUserRatingApi 只需要 userId(Cookie 可选),所以 Preview API 模式、未登录
游戏服务器时也能出图,只是没有 FC/AP/FS 标记。导出用 RepaintBoundary 抓
全尺寸 PNG:原生走 share_plus(不支持文件分享的平台回退为落盘并提示路径),
web 走下载。

BEST35 与 BEST15 改为各自独立排版。原先沿用 Empurple 的跨列表全局序号,
BEST35 没打满时 BEST15 会整体上移混进上半区。

path_provider 会经 path_provider_android 2.3.x 拖入 jni 插件,而 jni 把
windows/linux 也声明成 ffiPlugin,桌面构建因此去链接 jvm.lib 并失败,
故用 dependency_overrides 钉住 path_provider_android <2.3.0。
This commit is contained in:
Cookiues committed 2026-09-25 22:25:35 +08:00
1 parent 24ccd628d1
commit 79fa0d7a20
67 files changed
+1563 -62

No files matched your search

+3 -3
View File
@@ -5,14 +5,14 @@
"version": "0.2.0", "version": "0.2.0",
"configurations": [ "configurations": [
{ {
"name": "Purarine", "name": "Ichino",
"request": "launch", "request": "launch",
"type": "dart", "type": "dart",
"program": "lib/main.dart", "program": "lib/main.dart",
"deviceId": "windows" "deviceId": "windows"
}, },
{ {
"name": "Purarine (profile mode)", "name": "Ichino (profile mode)",
"request": "launch", "request": "launch",
"type": "dart", "type": "dart",
"program": "lib/main.dart", "program": "lib/main.dart",
@@ -20,7 +20,7 @@
"flutterMode": "profile" "flutterMode": "profile"
}, },
{ {
"name": "Purarine (release mode)", "name": "Ichino (release mode)",
"request": "launch", "request": "launch",
"type": "dart", "type": "dart",
"program": "lib/main.dart", "program": "lib/main.dart",
+1 -1
View File
@@ -1,4 +1,4 @@
# Purarine # Ichino
A new Flutter project. A new Flutter project.
+2 -2
View File
@@ -5,7 +5,7 @@ plugins {
} }
android { android {
namespace = "dev.naominet.purarine" namespace = "dev.naominet.ichino"
compileSdk = flutter.compileSdkVersion compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion ndkVersion = flutter.ndkVersion
@@ -15,7 +15,7 @@ android {
} }
defaultConfig { defaultConfig {
applicationId = "dev.naominet.purarine" applicationId = "dev.naominet.ichino"
minSdk = flutter.minSdkVersion minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode versionCode = flutter.versionCode
+1 -1
View File
@@ -1,7 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
<application <application
android:label="Purarine" android:label="Ichino"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true"> android:usesCleartextTraffic="true">
@@ -1,4 +1,4 @@
package dev.naominet.purarine package dev.naominet.ichino
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import com.google.mlkit.vision.barcode.BarcodeScanning import com.google.mlkit.vision.barcode.BarcodeScanning
@@ -9,7 +9,7 @@ import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
class MainActivity : FlutterActivity() { class MainActivity : FlutterActivity() {
private val CHANNEL = "dev.naominet.purarine/qr_scanner" private val CHANNEL = "dev.naominet.ichino/qr_scanner"
override fun configureFlutterEngine(flutterEngine: FlutterEngine) { override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine) super.configureFlutterEngine(flutterEngine)
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 923 KiB

+6 -6
View File
@@ -475,7 +475,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
PRODUCT_BUNDLE_IDENTIFIER = dev.naominet.purarine; PRODUCT_BUNDLE_IDENTIFIER = dev.naominet.ichino;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
@@ -493,7 +493,7 @@
DEVELOPMENT_TEAM = 3ACWHAQMGP; DEVELOPMENT_TEAM = 3ACWHAQMGP;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = dev.naominet.purarine.RunnerTests; PRODUCT_BUNDLE_IDENTIFIER = dev.naominet.ichino.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@@ -512,7 +512,7 @@
DEVELOPMENT_TEAM = 3ACWHAQMGP; DEVELOPMENT_TEAM = 3ACWHAQMGP;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = dev.naominet.purarine.RunnerTests; PRODUCT_BUNDLE_IDENTIFIER = dev.naominet.ichino.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
@@ -529,7 +529,7 @@
DEVELOPMENT_TEAM = 3ACWHAQMGP; DEVELOPMENT_TEAM = 3ACWHAQMGP;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = dev.naominet.purarine.RunnerTests; PRODUCT_BUNDLE_IDENTIFIER = dev.naominet.ichino.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
@@ -663,7 +663,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
PRODUCT_BUNDLE_IDENTIFIER = dev.naominet.purarine; PRODUCT_BUNDLE_IDENTIFIER = dev.naominet.ichino;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@@ -688,7 +688,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
PRODUCT_BUNDLE_IDENTIFIER = dev.naominet.purarine; PRODUCT_BUNDLE_IDENTIFIER = dev.naominet.ichino;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
+2 -2
View File
@@ -7,7 +7,7 @@
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string> <string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
<string>Purarine</string> <string>Ichino</string>
<key>CFBundleExecutable</key> <key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string> <string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key> <key>CFBundleIdentifier</key>
@@ -15,7 +15,7 @@
<key>CFBundleInfoDictionaryVersion</key> <key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string> <string>6.0</string>
<key>CFBundleName</key> <key>CFBundleName</key>
<string>purarine</string> <string>ichino</string>
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
+1 -1
View File
@@ -14,7 +14,7 @@ class SceneDelegate: FlutterSceneDelegate {
guard let controller = window?.rootViewController as? FlutterViewController else { return } guard let controller = window?.rootViewController as? FlutterViewController else { return }
let channel = FlutterMethodChannel( let channel = FlutterMethodChannel(
name: "dev.naominet.purarine/qr_scanner", name: "dev.naominet.ichino/qr_scanner",
binaryMessenger: controller.binaryMessenger binaryMessenger: controller.binaryMessenger
) )
+19 -2
View File
@@ -4,12 +4,13 @@ class AppStrings {
// Tab // Tab
static const tabHome = '主页'; static const tabHome = '主页';
static const tabTickets = '票据'; static const tabTickets = '票据';
static const tabBest50 = 'B50';
static const tabRisk = '风险'; static const tabRisk = '风险';
static const tabSettings = '设置'; static const tabSettings = '设置';
static const tabAbout = '关于'; static const tabAbout = '关于';
// Login // Login
static const appTitle = 'Purarine'; static const appTitle = 'Ichino';
static const loginSubtitle = '扫描或输入二维码进行登录'; static const loginSubtitle = '扫描或输入二维码进行登录';
static const qrCodeToken = 'QR Code 令牌'; static const qrCodeToken = 'QR Code 令牌';
static const qrHint = '粘贴二维码解析内容...'; static const qrHint = '粘贴二维码解析内容...';
@@ -231,8 +232,24 @@ class AppStrings {
static String musicRiskHubCooldownNotice(int remaining) => static String musicRiskHubCooldownNotice(int remaining) =>
'登录后需冷却 $ticketCooldownSeconds 秒,剩余 $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 // About
static const aboutTitle = 'Purarine'; static const aboutTitle = 'Ichino';
static const aboutDesc = '基于 QR Code 的 maimai DX 街机网络登录客户端。'; static const aboutDesc = '基于 QR Code 的 maimai DX 街机网络登录客户端。';
static const credits = '致谢'; static const credits = '致谢';
static const creditBuiltWith = '构建框架'; static const creditBuiltWith = '构建框架';
+102
View File
@@ -0,0 +1,102 @@
/// GetUserRatingApi 的 `userRating` 结构,移植自 Empurple 的 `UserRatingData`。
///
/// 服务端已按 RA 排好 BEST 35 / BEST 15,客户端不再重排。
enum MusicLevel {
basic,
advanced,
expert,
master,
reMaster;
static MusicLevel fromInt(int level) =>
level >= 0 && level < MusicLevel.values.length
? MusicLevel.values[level]
: MusicLevel.basic;
}
class UserRatingItemBean {
final int musicId;
final int level;
final int achievement;
final int comboStatus;
final int syncStatus;
final int romVersion;
const UserRatingItemBean({
required this.musicId,
required this.level,
required this.achievement,
required this.comboStatus,
required this.syncStatus,
required this.romVersion,
});
/// 达成率百分比数值,接口原值 1001234 => 100.1234。
double get achievementPercent => achievement / 10000.0;
/// 对应 Empurple 的 `formatRatingSimple`。
String get achievementText => '${achievementPercent.toStringAsFixed(4)}%';
/// DX 谱的 musicId 会加 10000 偏移。
bool get isDx => musicId > 10000;
MusicLevel get musicLevel => MusicLevel.fromInt(level);
factory UserRatingItemBean.fromJson(Map<String, dynamic> json) {
int toInt(dynamic v) {
if (v == null) return 0;
if (v is int) return v;
if (v is num) return v.toInt();
if (v is String) return int.tryParse(v) ?? 0;
return 0;
}
return UserRatingItemBean(
musicId: toInt(json['musicId']),
level: toInt(json['level']),
achievement: toInt(json['achievement']),
comboStatus: toInt(json['comboStatus']),
syncStatus: toInt(json['syncStatus']),
romVersion: toInt(json['romVersion']),
);
}
}
class UserRatingBean {
final int userId;
final List<UserRatingItemBean> ratingList;
final List<UserRatingItemBean> newRatingList;
const UserRatingBean({
required this.userId,
required this.ratingList,
required this.newRatingList,
});
static List<UserRatingItemBean> _parseList(dynamic raw) {
if (raw is! List) return const [];
return raw
.whereType<Map<String, dynamic>>()
.map(UserRatingItemBean.fromJson)
.toList();
}
factory UserRatingBean.fromJson(Map<String, dynamic> json) {
int toInt(dynamic v) {
if (v == null) return 0;
if (v is int) return v;
if (v is num) return v.toInt();
if (v is String) return int.tryParse(v) ?? 0;
return 0;
}
final userRating =
(json['userRating'] as Map<String, dynamic>?) ?? const {};
return UserRatingBean(
userId: toInt(json['userId']),
ratingList: _parseList(userRating['ratingList']),
newRatingList: _parseList(userRating['newRatingList']),
);
}
}
+320
View File
@@ -0,0 +1,320 @@
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import '../config/strings.dart';
import '../config/title_server_config.dart';
import '../models/user_rating.dart';
import '../services/music_data_service.dart';
import '../services/png_export_service.dart';
import '../services/rating_calculator.dart';
import '../services/title_api_service.dart';
import '../widgets/best50_poster.dart';
/// Best 50 成绩图页面:负责取数与导出,海报本身在 `widgets/best50_poster.dart`。
///
/// GetUserRatingApi 只需要 userId(Cookie 是可选的,与 Empurple 的
/// `UserRatingRequest` + `MaimaiApiClient` 一致),所以 Preview API 模式、
/// 未登录游戏服务器时同样能拉出 B50,只是拿不到 FC/AP、FS 这类详细标记。
class Best50Page extends StatefulWidget {
final int userId;
final String? cookies;
final String userName;
final int iconId;
final int playerRating;
const Best50Page({
super.key,
required this.userId,
this.cookies,
required this.userName,
required this.iconId,
required this.playerRating,
});
@override
State<Best50Page> createState() => _Best50PageState();
}
class _Best50PageState extends State<Best50Page> {
final GlobalKey _posterKey = GlobalKey();
UserRatingBean? _rating;
String? _error;
bool _loading = true;
bool _exporting = false;
bool _musicReady = false;
@override
void initState() {
super.initState();
_bootstrap();
}
@override
void didUpdateWidget(Best50Page oldWidget) {
super.didUpdateWidget(oldWidget);
// 本页在 IndexedStack 里会先于 preview/登录请求被构建,cookies 到位后补一次加载。
if (oldWidget.cookies != widget.cookies &&
(_rating == null || _error != null)) {
_bootstrap();
}
}
Future<void> _bootstrap() async {
if (!TitleServerConfigHolder().isConfigured) {
_fail(AppStrings.ticketNotConfigured);
return;
}
setState(() {
_loading = true;
_error = null;
});
try {
final service = TitleApiService(
TitleServerConfigHolder().config!,
cookies: widget.cookies,
);
final rating = await service.getUserRatingTyped(widget.userId);
if (!mounted) return;
setState(() {
_rating = rating;
_loading = false;
});
// 曲名/定数缺失时仍能出图(RA 会是 0),所以不当作致命错误。
await MusicDataService.instance.load();
if (!mounted) return;
setState(() => _musicReady = true);
} on TitleApiException catch (e) {
_fail(e.message);
} catch (e) {
_fail('$e');
}
}
void _fail(String message) {
setState(() {
_loading = false;
_rating = null;
_error = message;
});
}
List<Best50CardData> _resolve(List<UserRatingItemBean> items) {
final music = MusicDataService.instance;
return items.map((item) {
final result = RatingCalculator.compute(
music.getDs(item.musicId, item.level),
item.achievementPercent,
);
return Best50CardData(
musicId: item.musicId,
level: item.musicLevel,
title: music.getTitle(item.musicId),
achievementText: item.achievementText,
comboStatus: item.comboStatus,
syncStatus: item.syncStatus,
ra: result.ra,
rate: result.rate,
);
}).toList();
}
Future<void> _export() async {
setState(() => _exporting = true);
try {
final boundary =
_posterKey.currentContext?.findRenderObject()
as RenderRepaintBoundary?;
if (boundary == null) throw Exception('海报尚未完成布局');
final image = await boundary.toImage(pixelRatio: Best50Poster.exportPixelRatio);
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
image.dispose();
if (byteData == null) throw Exception('PNG 编码失败');
final path = await exportPng(
byteData.buffer.asUint8List(),
'best50_${widget.userId}.png',
);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(AppStrings.best50Exported(path))),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(AppStrings.best50ExportFailed('$e'))),
);
} finally {
if (mounted) setState(() => _exporting = false);
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
if (_loading) {
return const Center(child: CircularProgressIndicator());
}
final rating = _rating;
if (rating == null) {
return _StateView(
message: _error ?? AppStrings.loadFailed,
onRetry: _bootstrap,
);
}
if (rating.ratingList.isEmpty && rating.newRatingList.isEmpty) {
return _StateView(
message: AppStrings.best50Empty,
onRetry: _bootstrap,
icon: Icons.bar_chart_outlined,
);
}
final best35 = _resolve(
rating.ratingList.take(Best50Poster.maxBest35).toList(),
);
final best15 = _resolve(
rating.newRatingList.take(Best50Poster.maxBest15).toList(),
);
return Scaffold(
body: Column(
children: [
if (!_musicReady)
_NoticeBanner(theme: theme, text: AppStrings.best50NoMusicData),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: _exporting ? null : _export,
icon: _exporting
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.download, size: 20),
label: Text(
_exporting
? AppStrings.best50Exporting
: AppStrings.best50Export,
),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
),
),
Expanded(
child: InteractiveViewer(
maxScale: 6,
boundaryMargin: const EdgeInsets.all(24),
// FittedBox 会按视口把固定尺寸的海报缩出来;它给子节点无限约束,
// 所以海报仍保持 1762x1850,RepaintBoundary 录到的也是原始尺寸
// (祖先变换不会被烘进边界里),导出不会糊。
child: FittedBox(
fit: BoxFit.contain,
alignment: Alignment.topCenter,
child: RepaintBoundary(
key: _posterKey,
child: Best50Poster(
userName: widget.userName,
iconId: widget.iconId,
playerRating: widget.playerRating,
best35: best35,
best15: best15,
),
),
),
),
),
],
),
);
}
}
// =============================================================================
// 页面级辅助组件(非海报部分,跟随 Ichino 现有卡片/横幅风格)
// =============================================================================
class _NoticeBanner extends StatelessWidget {
final ThemeData theme;
final String text;
const _NoticeBanner({required this.theme, required this.text});
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
margin: const EdgeInsets.fromLTRB(16, 12, 16, 0),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: theme.colorScheme.tertiaryContainer.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(10),
),
child: Text(
text,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onTertiaryContainer,
),
),
);
}
}
class _StateView extends StatelessWidget {
final String message;
final VoidCallback onRetry;
final IconData icon;
const _StateView({
required this.message,
required this.onRetry,
this.icon = Icons.error_outline,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 48, color: theme.colorScheme.error),
const SizedBox(height: 12),
Text(
message,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 20),
FilledButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh, size: 20),
label: const Text(AppStrings.retry),
),
],
),
),
);
}
}
+11 -1
View File
@@ -8,6 +8,7 @@ import '../models/user_data.dart';
import '../models/user_preview.dart'; import '../models/user_preview.dart';
import '../services/title_api_service.dart'; import '../services/title_api_service.dart';
import 'about_page.dart'; import 'about_page.dart';
import 'best50_page.dart';
import 'high_risk_feature_page.dart'; import 'high_risk_feature_page.dart';
import 'settings_page.dart'; import 'settings_page.dart';
import 'ticket_page.dart'; import 'ticket_page.dart';
@@ -42,6 +43,7 @@ class _HomePageState extends State<HomePage> {
static const _tabTitles = [ static const _tabTitles = [
AppStrings.tabHome, AppStrings.tabHome,
AppStrings.tabTickets, AppStrings.tabTickets,
AppStrings.tabBest50,
AppStrings.tabRisk, AppStrings.tabRisk,
AppStrings.tabSettings, AppStrings.tabSettings,
AppStrings.tabAbout, AppStrings.tabAbout,
@@ -218,6 +220,13 @@ class _HomePageState extends State<HomePage> {
playerRating: _userData?.playerRating ?? _preview?.playerRating ?? 0, playerRating: _userData?.playerRating ?? _preview?.playerRating ?? 0,
onLogoutRequested: _session.gameLogin != null ? _logoutSession : null, 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( HighRiskFeaturePage(
userId: widget.userId, userId: widget.userId,
cookies: _session.cookies, cookies: _session.cookies,
@@ -237,6 +246,7 @@ class _HomePageState extends State<HomePage> {
destinations: const [ destinations: const [
NavigationDestination(icon: Icon(Icons.home_outlined), selectedIcon: Icon(Icons.home), label: AppStrings.tabHome), 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.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.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.settings_outlined), selectedIcon: Icon(Icons.settings), label: AppStrings.tabSettings),
NavigationDestination(icon: Icon(Icons.info_outlined), selectedIcon: Icon(Icons.info), label: AppStrings.tabAbout), 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), const SizedBox(height: 24),
FilledButton.icon( FilledButton.icon(
onPressed: () => setState(() => _currentTab = 3), onPressed: () => setState(() => _currentTab = 4),
icon: const Icon(Icons.settings, size: 20), icon: const Icon(Icons.settings, size: 20),
label: const Text(AppStrings.openSettings), label: const Text(AppStrings.openSettings),
), ),
+8
View File
@@ -0,0 +1,8 @@
import 'music_data_cache_native.dart'
if (dart.library.html) 'music_data_cache_web.dart' as impl;
/// 乐曲元数据的磁盘缓存,Web 平台为空实现。读失败返回 null,写失败静默忽略。
Future<String?> readTextCache(String fileName) => impl.readTextCache(fileName);
Future<void> writeTextCache(String fileName, String content) =>
impl.writeTextCache(fileName, content);
+29
View File
@@ -0,0 +1,29 @@
import 'dart:io';
/// 乐曲元数据是可重新下载的参考数据,放系统临时目录即可,
/// 不引入 path_provider(它经 path_provider_android 拖进 jni 插件,
/// jni 会在 Windows 上强行链接 jvm.lib)。
Future<String?> readTextCache(String fileName) async {
try {
final file = await _file(fileName);
if (!await file.exists()) return null;
final content = await file.readAsString();
return content.isEmpty ? null : content;
} catch (_) {
return null;
}
}
Future<void> writeTextCache(String fileName, String content) async {
try {
await (await _file(fileName)).writeAsString(content);
} catch (_) {
// 缓存是尽力而为,失败只影响下次启动的加载速度。
}
}
Future<File> _file(String fileName) async {
final dir = Directory('${Directory.systemTemp.path}/ichino_cache');
await dir.create(recursive: true);
return File('${dir.path}/$fileName');
}
+3
View File
@@ -0,0 +1,3 @@
Future<String?> readTextCache(String fileName) async => null;
Future<void> writeTextCache(String fileName, String content) async {}
+93
View File
@@ -0,0 +1,93 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'music_data_cache.dart';
class MusicInfo {
final String title;
final Map<int, double> dsByLevel;
const MusicInfo({required this.title, required this.dsByLevel});
}
/// 乐曲元数据来源,移植自 Empurple 的 `MusicDataProvider`。
///
/// 游戏接口的 ratingList 只有 musicId,曲名与各难度定数需要另行获取。
class MusicDataService {
static const String _musicDataUrl =
'https://www.diving-fish.com/api/maimaidxprober/music_data';
static const String _cacheFileName = 'music_data.json';
MusicDataService._();
static final MusicDataService instance = MusicDataService._();
final Map<int, MusicInfo> _byId = {};
Future<void>? _inFlight;
String getTitle(int musicId) => _byId[musicId]?.title ?? 'Unknown';
/// 缺失定数时返回 0,RA 也随之为 0,与 Empurple 行为一致。
double getDs(int musicId, int level) =>
_byId[musicId]?.dsByLevel[level] ?? 0.0;
/// 并发调用共享同一次下载。
Future<void> load() {
if (_byId.isNotEmpty) return Future.value();
return _inFlight ??= _load().whenComplete(() => _inFlight = null);
}
Future<void> _load() async {
final cached = await readTextCache(_cacheFileName);
final content = cached ?? await _download();
_parse(content);
if (cached == null) {
await writeTextCache(_cacheFileName, content);
}
}
Future<String> _download() async {
final response = await http
.get(Uri.parse(_musicDataUrl), headers: const {'User-Agent': 'Ichino/1.0'})
.timeout(const Duration(seconds: 20));
if (response.statusCode != 200) {
throw Exception('music_data HTTP ${response.statusCode}');
}
return response.body;
}
void _parse(String content) {
final decoded = jsonDecode(content);
if (decoded is! List) return;
_byId.clear();
for (final item in decoded.whereType<Map<String, dynamic>>()) {
final id = _toInt(item['id']);
if (id == 0) continue;
final ds = item['ds'];
_byId[id] = MusicInfo(
title: item['title']?.toString() ?? 'Unknown',
dsByLevel: ds is List
? {
for (var level = 0; level < ds.length; level++)
level: _toDouble(ds[level]),
}
: const {},
);
}
}
static int _toInt(dynamic v) {
if (v is int) return v;
if (v is num) return v.toInt();
if (v is String) return int.tryParse(v) ?? 0;
return 0;
}
static double _toDouble(dynamic v) {
if (v is double) return v;
if (v is int) return v.toDouble();
if (v is String) return double.tryParse(v) ?? 0.0;
return 0.0;
}
}
+10
View File
@@ -0,0 +1,10 @@
import 'dart:typed_data';
import 'png_export_service_native.dart'
if (dart.library.html) 'png_export_service_web.dart' as impl;
/// 导出一张 PNG:原生平台落临时盘并拉起系统分享,Web 走下载或 Web Share API。
///
/// 返回原生平台上的落盘路径(用于分享能力缺失时提示用户),Web 返回 null。
Future<String?> exportPng(Uint8List bytes, String fileName) =>
impl.exportPng(bytes, fileName);
@@ -0,0 +1,22 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:share_plus/share_plus.dart';
Future<String?> exportPng(Uint8List bytes, String fileName) async {
final file = File('${Directory.systemTemp.path}/$fileName');
await file.writeAsBytes(bytes);
try {
await SharePlus.instance.share(
ShareParams(
files: [XFile.fromData(bytes, name: fileName, mimeType: 'image/png')],
fileNameOverrides: [fileName],
),
);
} on UnimplementedError {
// Linux 与 Windows 10 RS5 之前不支持分享文件,文件已落盘,返回路径即可。
}
return file.path;
}
+15
View File
@@ -0,0 +1,15 @@
import 'dart:typed_data';
import 'package:share_plus/share_plus.dart';
Future<String?> exportPng(Uint8List bytes, String fileName) async {
await SharePlus.instance.share(
ShareParams(
files: [
XFile.fromData(bytes, name: fileName, mimeType: 'image/png'),
],
fileNameOverrides: [fileName],
),
);
return null;
}
+1 -1
View File
@@ -1,6 +1,6 @@
import 'package:flutter/services.dart'; 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 { Future<String?> decodeQRFromBytes(Uint8List bytes) async {
try { try {
+65
View File
@@ -0,0 +1,65 @@
/// RA / 评级计算,移植自 Empurple 的 `RatingCalculator`。
///
/// `achievement` 传入百分比数值(接口原值 1001234 => 100.1234),`ds` 为谱面定数。
class RaResult {
final int ra;
final String rate;
const RaResult(this.ra, this.rate);
}
/// 达成率区间上界 / RA 系数 / 评级,顺序与 Empurple 的 when 分支一致。
const List<(int, int, String)> _bands = [
(100000, 0, 'D'),
(200000, 16, 'D'),
(300000, 32, 'D'),
(400000, 48, 'D'),
(500000, 64, 'D'),
(600000, 80, 'C'),
(700000, 96, 'B'),
(750000, 112, 'BB'),
(799999, 120, 'BBB'),
(800000, 128, 'BBB'),
(900000, 136, 'A'),
(940000, 152, 'AA'),
(969999, 168, 'AAA'),
(970000, 176, 'AAA'),
(980000, 200, 'S'),
(989999, 203, 'Sp'),
(990000, 206, 'Sp'),
(995000, 208, 'SS'),
(999999, 211, 'SSp'),
(1000000, 214, 'SSp'),
(1004999, 216, 'SSS'),
(1005000, 222, 'SSS'),
];
const int _maxAchievement = 1005000;
const int _ssspOffset = 224;
class RatingCalculator {
RatingCalculator._();
static int computeRa(double ds, double achievement) =>
compute(ds, achievement).ra;
static String computeRaRate(double ds, double achievement) =>
compute(ds, achievement).rate;
static RaResult compute(double ds, double achievement) {
final achievementRaw =
(achievement * 10000.0).round().clamp(0, _maxAchievement);
var offset = _ssspOffset;
var rate = 'SSSp';
for (final (upperBound, bandOffset, bandRate) in _bands) {
if (achievementRaw < upperBound) {
offset = bandOffset;
rate = bandRate;
break;
}
}
final scoreRate = (ds * 10.0).round();
final ra = scoreRate * achievementRaw * offset ~/ 100000000;
return RaResult(ra, rate);
}
}
+6
View File
@@ -10,6 +10,7 @@ import 'package:pointycastle/export.dart';
import '../config/title_server_config.dart'; import '../config/title_server_config.dart';
import '../models/user_data.dart'; import '../models/user_data.dart';
import '../models/user_preview.dart'; import '../models/user_preview.dart';
import '../models/user_rating.dart';
class TitleApiException implements Exception { class TitleApiException implements Exception {
final String message; final String message;
@@ -354,6 +355,11 @@ class TitleApiService {
return _callApi('GetUserRatingApi', {'userId': userId}, userId); 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 { Future<Map<String, dynamic>> getUserCharge(int userId) async {
return _callApi('GetUserChargeApi', {'userId': userId}, userId); return _callApi('GetUserChargeApi', {'userId': userId}, userId);
} }
+350
View File
@@ -0,0 +1,350 @@
import 'package:flutter/material.dart';
import '../config/strings.dart';
import '../models/user_rating.dart';
/// 海报上一张卡片所需的数据,由调用方解析好(曲名、定数、RA)。
class Best50CardData {
final int musicId;
final MusicLevel level;
final String title;
final String achievementText;
final int comboStatus;
final int syncStatus;
final int ra;
final String rate;
const Best50CardData({
required this.musicId,
required this.level,
required this.title,
required this.achievementText,
required this.comboStatus,
required this.syncStatus,
required this.ra,
required this.rate,
});
/// DX 谱的 musicId 带 10000 偏移。
bool get isDx => musicId > 10000;
}
/// Best 50 成绩海报,按 Empurple `Best50ImageRenderer` + `ImageBuilder` 的原始
/// 像素坐标复现:底图 `assets/b50/base.png` 为 1762x1850,卡片 300x100、
/// 每行 5 张、Best35 占 0..6 行、Best15 从第 7 行起跳到 1250 那条分隔带下方。
///
/// 顶部面板、舞萌DX logo、Best 35 / Best 15 药丸标签与页脚线都已经画在底图里,
/// 这里只叠加头像、昵称、Rating 行、50 张卡和页脚文字。
class Best50Poster extends StatelessWidget {
static const double canvasWidth = 1762;
static const double canvasHeight = 1850;
static const double exportPixelRatio = 2;
static const int maxBest35 = 35;
static const int maxBest15 = 15;
// 卡片栅格(与 Empurple 一致)
static const int columnsPerRow = 5;
static const double cardWidth = 300;
static const double cardHeight = 100;
static const double columnPitch = 320;
static const double rowPitch = 120;
static const double gridLeft = 65;
static const double gridTopFirstBlock = 350;
static const double gridTopSecondBlock = 1250;
static const Color inkColor = Color(0xFF3D3D3D);
static const Color cardColor = Color(0xFFEDEAFF);
final String userName;
final int iconId;
final int playerRating;
final List<Best50CardData> best35;
final List<Best50CardData> best15;
const Best50Poster({
super.key,
required this.userName,
required this.iconId,
required this.playerRating,
required this.best35,
required this.best15,
});
/// 两个区块各自独立排版:BEST35 固定从 [gridTopFirstBlock] 起、BEST15 固定从
/// [gridTopSecondBlock] 起。不能按「35+15 拼接后的全局序号」算行,否则 BEST35
/// 没打满时 BEST15 会整体上移、混进上半区。
static double cardLeft(int indexInBlock) =>
gridLeft + columnPitch * (indexInBlock % columnsPerRow);
static double cardTopBest35(int indexInBlock) =>
gridTopFirstBlock + rowPitch * (indexInBlock ~/ columnsPerRow);
static double cardTopBest15(int indexInBlock) =>
gridTopSecondBlock + rowPitch * (indexInBlock ~/ columnsPerRow);
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final cards35 = best35.take(maxBest35).toList();
final cards15 = best15.take(maxBest15).toList();
final ra35 = cards35.fold(0, (sum, card) => sum + card.ra);
final ra15 = cards15.fold(0, (sum, card) => sum + card.ra);
return SizedBox(
width: canvasWidth,
height: canvasHeight,
child: Stack(
clipBehavior: Clip.hardEdge,
children: [
Positioned.fill(
child: Image.asset(
'assets/b50/base.png',
fit: BoxFit.fill,
filterQuality: FilterQuality.medium,
),
),
if (iconId > 0)
Positioned(
left: 115,
top: 108,
child: ClipRRect(
borderRadius: BorderRadius.circular(24),
child: Image.network(
'https://assets2.lxns.net/maimai/icon/$iconId.png',
width: 112,
height: 112,
fit: BoxFit.cover,
errorBuilder: (_, _, _) => const SizedBox.shrink(),
),
),
),
Positioned(
left: 260,
top: 118,
child: Text(
userName.isEmpty ? AppStrings.unknownUser : userName,
style: TextStyle(
fontSize: 48,
fontWeight: FontWeight.bold,
height: 1.15,
color: inkColor,
fontFamily: theme.textTheme.bodyLarge?.fontFamily,
),
),
),
Positioned(
left: 260,
top: 178,
child: Text(
AppStrings.best50RatingSum(ra35, ra15),
style: const TextStyle(
fontSize: 24,
height: 1.15,
color: inkColor,
),
),
),
Positioned(
left: 260,
top: 204,
child: Text(
AppStrings.rating(playerRating),
style: const TextStyle(
fontSize: 24,
height: 1.15,
color: inkColor,
),
),
),
for (var i = 0; i < cards35.length; i++)
Positioned(
left: cardLeft(i),
top: cardTopBest35(i),
child: _RatingCard(data: cards35[i]),
),
for (var i = 0; i < cards15.length; i++)
Positioned(
left: cardLeft(i),
top: cardTopBest15(i),
child: _RatingCard(data: cards15[i]),
),
Positioned(
left: 70,
top: 1718,
child: Text(
AppStrings.best50Footer,
style: const TextStyle(
fontSize: 24,
height: 1.15,
color: inkColor,
),
),
),
],
),
);
}
}
class _RatingCard extends StatelessWidget {
final Best50CardData data;
const _RatingCard({required this.data});
static const Map<int, String> comboAssets = {
1: 'FC',
2: 'FCp',
3: 'AP',
4: 'APp',
};
static const Map<int, String> syncAssets = {
1: 'FS',
2: 'FSp',
3: 'FSD',
4: 'FSDp',
};
@override
Widget build(BuildContext context) {
return SizedBox(
width: Best50Poster.cardWidth,
height: Best50Poster.cardHeight,
child: Stack(
clipBehavior: Clip.hardEdge,
children: [
Positioned.fill(
child: Container(
decoration: BoxDecoration(
color: Best50Poster.cardColor,
borderRadius: BorderRadius.circular(30),
),
),
),
Positioned(
left: 10,
top: 10,
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: Image.network(
'https://assets2.lxns.net/maimai/jacket/'
'${data.isDx ? data.musicId - 10000 : data.musicId}.png',
width: 80,
height: 80,
fit: BoxFit.cover,
errorBuilder: (_, _, _) => Container(
width: 80,
height: 80,
color: Colors.black12,
),
),
),
),
Positioned(
left: 100,
top: 20,
width: data.isDx ? 124 : 188,
child: Text(
data.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 16,
height: 1.15,
color: Best50Poster.inkColor,
),
),
),
Positioned(
left: 100,
top: 40,
child: Text(
data.achievementText,
style: const TextStyle(
fontSize: 26,
height: 1.15,
fontWeight: FontWeight.bold,
color: Best50Poster.inkColor,
),
),
),
if (data.isDx)
Positioned(
left: 230,
top: 20,
child: Image.asset(
'assets/b50/DX.png',
width: 60,
height: 21,
fit: BoxFit.contain,
),
),
Positioned(
left: 95,
top: 67,
child: Image.asset(
'assets/b50/UI_TTR_Rank_${data.rate}.png',
width: 55,
height: 28,
fit: BoxFit.contain,
),
),
if (comboAssets[data.comboStatus] case final asset?)
Positioned(
left: 148,
top: 66,
child: Image.asset(
'assets/b50/UI_CHR_PlayBonus_$asset.png',
width: 32,
height: 32,
fit: BoxFit.contain,
),
),
if (syncAssets[data.syncStatus] case final asset?)
Positioned(
left: 178,
top: 66,
child: Image.asset(
'assets/b50/UI_CHR_PlayBonus_$asset.png',
width: 32,
height: 32,
fit: BoxFit.contain,
),
),
Positioned(
left: 225,
top: 70,
child: Container(
width: 68,
height: 26,
alignment: Alignment.center,
decoration: BoxDecoration(
color: _LevelPill.background[data.level],
borderRadius: BorderRadius.circular(13),
),
child: Text(
'RA ${data.ra}',
style: const TextStyle(
fontSize: 12,
height: 1.0,
color: Best50Poster.inkColor,
),
),
),
),
],
),
);
}
}
/// 难度色是游戏内语义色,与 Empurple 一致,不映射到 Material 色板。
class _LevelPill {
static const Map<MusicLevel, Color> background = {
MusicLevel.basic: Color(0xFF628C7B),
MusicLevel.advanced: Color(0xFFB58300),
MusicLevel.expert: Color(0xFFD37A7A),
MusicLevel.master: Color(0xFF6750A4),
MusicLevel.reMaster: Color(0xFFD0C8FF),
};
}
+2 -2
View File
@@ -4,10 +4,10 @@ project(runner LANGUAGES CXX)
# The name of the executable created for the application. Change this to change # The name of the executable created for the application. Change this to change
# the on-disk name of your application. # the on-disk name of your application.
set(BINARY_NAME "purarine") set(BINARY_NAME "ichino")
# The unique GTK application identifier for this application. See: # The unique GTK application identifier for this application. See:
# https://wiki.gnome.org/HowDoI/ChooseApplicationID # 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 # Explicitly opt in to modern CMake behaviors to avoid warnings with recent
# versions of CMake. # versions of CMake.
@@ -7,9 +7,13 @@
#include "generated_plugin_registrant.h" #include "generated_plugin_registrant.h"
#include <file_selector_linux/file_selector_plugin.h> #include <file_selector_linux/file_selector_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) { void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
file_selector_plugin_register_with_registrar(file_selector_linux_registrar); 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);
} }
+1
View File
@@ -4,6 +4,7 @@
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
file_selector_linux file_selector_linux
url_launcher_linux
) )
list(APPEND FLUTTER_FFI_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST
+2 -2
View File
@@ -45,11 +45,11 @@ static void my_application_activate(GApplication* application) {
if (use_header_bar) { if (use_header_bar) {
GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new());
gtk_widget_show(GTK_WIDGET(header_bar)); 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_header_bar_set_show_close_button(header_bar, TRUE);
gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); gtk_window_set_titlebar(window, GTK_WIDGET(header_bar));
} else { } else {
gtk_window_set_title(window, "Purarine"); gtk_window_set_title(window, "Ichino");
} }
gtk_window_set_default_size(window, 1280, 720); gtk_window_set_default_size(window, 1280, 720);
@@ -6,9 +6,11 @@ import FlutterMacOS
import Foundation import Foundation
import file_selector_macos import file_selector_macos
import share_plus
import shared_preferences_foundation import shared_preferences_foundation
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
} }
+9 -9
View File
@@ -65,7 +65,7 @@
331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = "<group>"; };
@@ -134,7 +134,7 @@
33CC10EE2044A3C60003C045 /* Products */ = { 33CC10EE2044A3C60003C045 /* Products */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
33CC10ED2044A3C60003C045 /* Purarine.app */, 33CC10ED2044A3C60003C045 /* Ichino.app */,
331C80D5294CF71000263BE5 /* RunnerTests.xctest */, 331C80D5294CF71000263BE5 /* RunnerTests.xctest */,
); );
name = Products; name = Products;
@@ -224,7 +224,7 @@
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
); );
productName = Runner; productName = Runner;
productReference = 33CC10ED2044A3C60003C045 /* Purarine.app */; productReference = 33CC10ED2044A3C60003C045 /* Ichino.app */;
productType = "com.apple.product-type.application"; productType = "com.apple.product-type.application";
}; };
/* End PBXNativeTarget section */ /* End PBXNativeTarget section */
@@ -395,10 +395,10 @@
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = dev.naominet.purarine.RunnerTests; PRODUCT_BUNDLE_IDENTIFIER = dev.naominet.ichino.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0; 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; name = Debug;
}; };
@@ -409,10 +409,10 @@
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = dev.naominet.purarine.RunnerTests; PRODUCT_BUNDLE_IDENTIFIER = dev.naominet.ichino.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0; 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; name = Release;
}; };
@@ -423,10 +423,10 @@
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = dev.naominet.purarine.RunnerTests; PRODUCT_BUNDLE_IDENTIFIER = dev.naominet.ichino.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0; 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; name = Profile;
}; };
@@ -15,7 +15,7 @@
<BuildableReference <BuildableReference
BuildableIdentifier = "primary" BuildableIdentifier = "primary"
BlueprintIdentifier = "33CC10EC2044A3C60003C045" BlueprintIdentifier = "33CC10EC2044A3C60003C045"
BuildableName = "Purarine.app" BuildableName = "Ichino.app"
BlueprintName = "Runner" BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj"> ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference> </BuildableReference>
@@ -33,7 +33,7 @@
<BuildableReference <BuildableReference
BuildableIdentifier = "primary" BuildableIdentifier = "primary"
BlueprintIdentifier = "33CC10EC2044A3C60003C045" BlueprintIdentifier = "33CC10EC2044A3C60003C045"
BuildableName = "Purarine.app" BuildableName = "Ichino.app"
BlueprintName = "Runner" BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj"> ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference> </BuildableReference>
@@ -49,7 +49,7 @@
<BuildableReference <BuildableReference
BuildableIdentifier = "primary" BuildableIdentifier = "primary"
BlueprintIdentifier = "33CC10EC2044A3C60003C045" BlueprintIdentifier = "33CC10EC2044A3C60003C045"
BuildableName = "Purarine.app" BuildableName = "Ichino.app"
BlueprintName = "Runner" BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj"> ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference> </BuildableReference>
@@ -84,7 +84,7 @@
<BuildableReference <BuildableReference
BuildableIdentifier = "primary" BuildableIdentifier = "primary"
BlueprintIdentifier = "33CC10EC2044A3C60003C045" BlueprintIdentifier = "33CC10EC2044A3C60003C045"
BuildableName = "Purarine.app" BuildableName = "Ichino.app"
BlueprintName = "Runner" BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj"> ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference> </BuildableReference>
@@ -101,7 +101,7 @@
<BuildableReference <BuildableReference
BuildableIdentifier = "primary" BuildableIdentifier = "primary"
BlueprintIdentifier = "33CC10EC2044A3C60003C045" BlueprintIdentifier = "33CC10EC2044A3C60003C045"
BuildableName = "Purarine.app" BuildableName = "Ichino.app"
BlueprintName = "Runner" BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj"> ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference> </BuildableReference>
+2 -2
View File
@@ -5,10 +5,10 @@
// 'flutter create' template. // 'flutter create' template.
// The application's name. By default this is also the title of the Flutter window. // 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 // The application's bundle identifier
PRODUCT_BUNDLE_IDENTIFIER = dev.naominet.purarine PRODUCT_BUNDLE_IDENTIFIER = dev.naominet.ichino
// The copyright displayed in application information // The copyright displayed in application information
PRODUCT_COPYRIGHT = Copyright © 2026 dev.naominet. All rights reserved. PRODUCT_COPYRIGHT = Copyright © 2026 dev.naominet. All rights reserved.
+161 -1
View File
@@ -57,6 +57,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.2" 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: collection:
dependency: transitive dependency: transitive
description: description:
@@ -113,6 +121,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.0" 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: file:
dependency: transitive dependency: transitive
description: description:
@@ -153,6 +169,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.9.3+5" version: "0.9.3+5"
fixnum:
dependency: transitive
description:
name: fixnum
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
url: "https://pub.dev"
source: hosted
version: "1.1.1"
flutter: flutter:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
@@ -184,6 +208,14 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
hooks:
dependency: transitive
description:
name: hooks
sha256: eaac480a35ec0814146c2c48d96aaa829e0e44a7662c88ae84c9edf4bc35651f
url: "https://pub.dev"
source: hosted
version: "2.2.0"
http: http:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -304,6 +336,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.1.0" version: "6.1.0"
logging:
dependency: transitive
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev"
source: hosted
version: "1.3.0"
matcher: matcher:
dependency: transitive dependency: transitive
description: description:
@@ -336,6 +376,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.0" 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: path:
dependency: transitive dependency: transitive
description: description:
@@ -344,6 +392,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.9.1" 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: path_provider_linux:
dependency: transitive dependency: transitive
description: description:
@@ -400,6 +472,38 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.5.0" 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: shared_preferences:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -517,6 +621,46 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.0" 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: vector_math:
dependency: transitive dependency: transitive
description: description:
@@ -541,6 +685,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.1" version: "1.1.1"
win32:
dependency: transitive
description:
name: win32
sha256: a0b93865d5644f11cf6a8c3f6db909f1ec168958b5805f6cc684adea957cd63d
url: "https://pub.dev"
source: hosted
version: "6.4.0"
xdg_directories: xdg_directories:
dependency: transitive dependency: transitive
description: description:
@@ -549,6 +701,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.0" version: "1.1.0"
yaml:
dependency: transitive
description:
name: yaml
sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea
url: "https://pub.dev"
source: hosted
version: "3.1.4"
sdks: sdks:
dart: ">=3.12.0 <4.0.0" dart: ">=3.12.0 <4.0.0"
flutter: ">=3.38.0" flutter: ">=3.38.4"
+11 -5
View File
@@ -1,4 +1,4 @@
name: purarine name: ichino
description: "A new Flutter project." description: "A new Flutter project."
# The following line prevents the package from being accidentally published to # The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages. # pub.dev using `flutter pub publish`. This is preferred for private packages.
@@ -38,6 +38,14 @@ dependencies:
archive: ^4.0.9 archive: ^4.0.9
pointycastle: ^3.9.1 pointycastle: ^3.9.1
shared_preferences: ^2.5.5 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: dev_dependencies:
flutter_test: flutter_test:
@@ -56,10 +64,8 @@ flutter:
# the material Icons class. # the material Icons class.
uses-material-design: true uses-material-design: true
# To add assets to your application, add an assets section, like this: assets:
# assets: - assets/b50/
# - images/a_dot_burr.jpeg
# - images/a_dot_ham.jpeg
# An image asset can refer to one or more resolution-specific "variants", see # An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/to/resolution-aware-images # https://flutter.dev/to/resolution-aware-images
+190
View File
@@ -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);
});
}
+80
View File
@@ -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);
});
});
}
+2 -2
View File
@@ -1,14 +1,14 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:purarine/main.dart'; import 'package:ichino/main.dart';
void main() { void main() {
testWidgets('App renders login page with app title', (WidgetTester tester) async { testWidgets('App renders login page with app title', (WidgetTester tester) async {
await tester.pumpWidget(const MyApp()); await tester.pumpWidget(const MyApp());
await tester.pump(); await tester.pump();
expect(find.text('Purarine'), findsWidgets); expect(find.text('Ichino'), findsWidgets);
expect(find.byType(MaterialApp), findsOneWidget); expect(find.byType(MaterialApp), findsOneWidget);
}); });
} }
+3 -3
View File
@@ -5,14 +5,14 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta content="IE=Edge" http-equiv="X-UA-Compatible"> <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="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black"> <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="apple-touch-icon" href="icons/Icon-192.png">
<link rel="icon" type="image/png" href="favicon.png"/> <link rel="icon" type="image/png" href="favicon.png"/>
<title>Purarine</title> <title>Ichino</title>
<link rel="manifest" href="manifest.json"> <link rel="manifest" href="manifest.json">
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/jsQR.js"></script> <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/jsQR.js"></script>
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "Purarine", "name": "Ichino",
"short_name": "Purarine", "short_name": "Ichino",
"start_url": ".", "start_url": ".",
"display": "standalone", "display": "standalone",
"background_color": "#0175C2", "background_color": "#0175C2",
+2 -2
View File
@@ -1,10 +1,10 @@
# Project-level configuration. # Project-level configuration.
cmake_minimum_required(VERSION 3.14) 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 name of the executable created for the application. Change this to change
# the on-disk name of your application. # 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 # Explicitly opt in to modern CMake behaviors to avoid warnings with recent
# versions of CMake. # versions of CMake.
@@ -7,8 +7,14 @@
#include "generated_plugin_registrant.h" #include "generated_plugin_registrant.h"
#include <file_selector_windows/file_selector_windows.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) { void RegisterPlugins(flutter::PluginRegistry* registry) {
FileSelectorWindowsRegisterWithRegistrar( FileSelectorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FileSelectorWindows")); registry->GetRegistrarForPlugin("FileSelectorWindows"));
SharePlusWindowsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi"));
UrlLauncherWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
} }
+2
View File
@@ -4,6 +4,8 @@
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
file_selector_windows file_selector_windows
share_plus
url_launcher_windows
) )
list(APPEND FLUTTER_FFI_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST
+4 -4
View File
@@ -90,12 +90,12 @@ BEGIN
BLOCK "040904e4" BLOCK "040904e4"
BEGIN BEGIN
VALUE "CompanyName", "dev.naominet" "\0" VALUE "CompanyName", "dev.naominet" "\0"
VALUE "FileDescription", "Purarine" "\0" VALUE "FileDescription", "Ichino" "\0"
VALUE "FileVersion", VERSION_AS_STRING "\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 "LegalCopyright", "Copyright (C) 2026 dev.naominet. All rights reserved." "\0"
VALUE "OriginalFilename", "purarine.exe" "\0" VALUE "OriginalFilename", "ichino.exe" "\0"
VALUE "ProductName", "Purarine" "\0" VALUE "ProductName", "Ichino" "\0"
VALUE "ProductVersion", VERSION_AS_STRING "\0" VALUE "ProductVersion", VERSION_AS_STRING "\0"
END END
END END
+1 -1
View File
@@ -27,7 +27,7 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev,
FlutterWindow window(project); FlutterWindow window(project);
Win32Window::Point origin(10, 10); Win32Window::Point origin(10, 10);
Win32Window::Size size(1280, 720); Win32Window::Size size(1280, 720);
if (! window.Create(L"Purarine", origin, size)) { if (! window.Create(L"Ichino", origin, size)) {
return EXIT_FAILURE; return EXIT_FAILURE;
} }
window.SetQuitOnClose(true); window.SetQuitOnClose(true);