This commit is contained in:
Cookiues committed 2026-08-22 16:52:33 +08:00
1 parent 0b3282da5c
commit 24ccd628d1
41 files changed
+2097 -1488

No files matched your search

+3 -3
View File
@@ -5,14 +5,14 @@
"version": "0.2.0", "version": "0.2.0",
"configurations": [ "configurations": [
{ {
"name": "ichino", "name": "Purarine",
"request": "launch", "request": "launch",
"type": "dart", "type": "dart",
"program": "lib/main.dart", "program": "lib/main.dart",
"deviceId": "windows" "deviceId": "windows"
}, },
{ {
"name": "ichino (profile mode)", "name": "Purarine (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": "ichino (release mode)", "name": "Purarine (release mode)",
"request": "launch", "request": "launch",
"type": "dart", "type": "dart",
"program": "lib/main.dart", "program": "lib/main.dart",
+2 -2
View File
@@ -1,5 +1,5 @@
{ {
"dart.flutterHotReloadOnSave": "all", "dart.flutterHotReloadOnSave": "all",
"dart.flutterSdkPath": "D:\\fluttersdk", "dart.flutterSdkPath": "D:\\flutterdev\\flutter",
"dart.sdkPath": "D:\\fluttersdk\\bin\\cache\\dart-sdk" "dart.sdkPath": "D:\\flutterdev\\flutter\\bin\\cache\\dart-sdk"
} }
+1 -1
View File
@@ -1,4 +1,4 @@
# ichino # Purarine
A new Flutter project. A new Flutter project.
+9
View File
@@ -7,6 +7,15 @@
# The following line activates a set of recommended lints for Flutter apps, # The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices. # packages, and plugins designed to encourage good coding practices.
analyzer:
exclude:
- build/**
- android/**
- ios/**
- web/**
- windows/**
- macos/**
- linux/**
include: package:flutter_lints/flutter.yaml include: package:flutter_lints/flutter.yaml
linter: linter:
+2 -2
View File
@@ -5,7 +5,7 @@ plugins {
} }
android { android {
namespace = "com.example.ichino" namespace = "dev.naominet.purarine"
compileSdk = flutter.compileSdkVersion compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion ndkVersion = flutter.ndkVersion
@@ -15,7 +15,7 @@ android {
} }
defaultConfig { defaultConfig {
applicationId = "com.example.ichino" applicationId = "dev.naominet.purarine"
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="ichino" android:label="Purarine"
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 com.example.ichino package dev.naominet.purarine
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 = "com.ichino/qr_scanner" private val CHANNEL = "dev.naominet.purarine/qr_scanner"
override fun configureFlutterEngine(flutterEngine: FlutterEngine) { override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine) super.configureFlutterEngine(flutterEngine)
+6 -6
View File
@@ -475,7 +475,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
PRODUCT_BUNDLE_IDENTIFIER = dev.ichino; PRODUCT_BUNDLE_IDENTIFIER = dev.naominet.purarine;
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 = com.example.ichino.RunnerTests; PRODUCT_BUNDLE_IDENTIFIER = dev.naominet.purarine.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 = com.example.ichino.RunnerTests; PRODUCT_BUNDLE_IDENTIFIER = dev.naominet.purarine.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 = com.example.ichino.RunnerTests; PRODUCT_BUNDLE_IDENTIFIER = dev.naominet.purarine.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.ichino; PRODUCT_BUNDLE_IDENTIFIER = dev.naominet.purarine;
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.ichino; PRODUCT_BUNDLE_IDENTIFIER = dev.naominet.purarine;
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>Ichino</string> <string>Purarine</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>ichino</string> <string>purarine</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: "com.ichino/qr_scanner", name: "dev.naominet.purarine/qr_scanner",
binaryMessenger: controller.binaryMessenger binaryMessenger: controller.binaryMessenger
) )
+69 -55
View File
@@ -4,12 +4,12 @@ class AppStrings {
// Tab // Tab
static const tabHome = '主页'; static const tabHome = '主页';
static const tabTickets = '票据'; static const tabTickets = '票据';
static const tabTransfer = '传分'; static const tabRisk = '风险';
static const tabSettings = '设置'; static const tabSettings = '设置';
static const tabAbout = '关于'; static const tabAbout = '关于';
// Login // Login
static const appTitle = 'Project Ichino'; static const appTitle = 'Purarine';
static const loginSubtitle = '扫描或输入二维码进行登录'; static const loginSubtitle = '扫描或输入二维码进行登录';
static const qrCodeToken = 'QR Code 令牌'; static const qrCodeToken = 'QR Code 令牌';
static const qrHint = '粘贴二维码解析内容...'; static const qrHint = '粘贴二维码解析内容...';
@@ -110,6 +110,7 @@ class AppStrings {
static const refreshTickets = '刷新数据'; static const refreshTickets = '刷新数据';
static const loading = '加载中...'; static const loading = '加载中...';
static const ticketNotSelected = '请先在功能票列表中选中一张票。'; static const ticketNotSelected = '请先在功能票列表中选中一张票。';
static const ticketStockNotEmpty = '该功能票已有库存,出于安全考虑不会继续发票。';
static const selectedTicket = '已选票'; static const selectedTicket = '已选票';
static const ticketUsed = '功能票使用完成'; static const ticketUsed = '功能票使用完成';
static const ticketCooldownSeconds = 60; static const ticketCooldownSeconds = 60;
@@ -117,11 +118,9 @@ class AppStrings {
'冷却中,剩余 $remaining 秒'; '冷却中,剩余 $remaining 秒';
static String ticketCooldownNotice(int remaining) => static String ticketCooldownNotice(int remaining) =>
'登录后需冷却 $ticketCooldownSeconds 秒,剩余 $remaining 秒后可使用功能票。'; '登录后需冷却 $ticketCooldownSeconds 秒,剩余 $remaining 秒后可使用功能票。';
static String packetCooldownNotice(int remaining) =>
'登录后需冷却 $ticketCooldownSeconds 秒,剩余 $remaining 秒后可上传成绩。';
// Settings // Settings
static const titleServerSettings = 'Title Server 设置'; static const titleServerSettings = '参数设置';
static const titleServerSection = 'Title Server'; static const titleServerSection = 'Title Server';
static const authServerSettings = 'Auth Server'; static const authServerSettings = 'Auth Server';
static const machineSettings = '机器信息'; static const machineSettings = '机器信息';
@@ -163,62 +162,77 @@ class AppStrings {
static const importFailed = '导入失败:剪贴板内容无效'; static const importFailed = '导入失败:剪贴板内容无效';
static const noConfigToExport = '没有可导出的配置'; static const noConfigToExport = '没有可导出的配置';
// Transfer Score (传分 / UpsertUserAll 上传成绩) // UnlockMusic (解锁歌曲 / UpsertUserAll 上传解锁道具)
static const transferTitle = '传送分数'; static const unlockFeatureTitle = '歌曲解锁';
static const transferDesc = static const unlockFeatureDesc = '通过上传 itemKind 5/6/7 道具解锁歌曲与谱面。';
'新版本机台容易崩溃导致成绩传不上去。在此填入一首歌的成绩,程序会作为一次有效游玩记录上传回服务器。'; static const unlockMusicIdLabel = '解锁歌曲 ID';
static const transferFetchData = '拉取数据'; static const unlockMusicIdHint = '例如 834';
static const transferFetching = '拉取中...'; static const unlockMusicOption = '解锁歌曲 (itemKind 5)';
static const transferRefetch = '重新拉取'; static const unlockMasterOption = '解锁 Master (itemKind 6)';
static const transferNotLoggedIn = '尚未登录游戏服务器,无法上传成绩。'; static const unlockRemasterOption = '解锁 Re:Master (itemKind 7)';
static const transferDataReady = '数据已就绪,填写下方成绩后点击上传。'; static const unlockNeedMusicId = '请填写要解锁的歌曲 ID。';
static const unlockNeedOption = '请至少勾选一项解锁内容。';
static const unlockNotLoggedIn = '尚未登录游戏服务器,无法执行解锁操作。';
static String unlockCooldownNotice(int remaining) =>
'登录后需冷却 $ticketCooldownSeconds 秒,剩余 $remaining 秒后可执行解锁操作。';
static const unlockFetchData = '拉取数据';
static const unlockFetching = '拉取中...';
static const unlockRefetch = '重新拉取';
static const unlockNoData = '⚠️ 未拉取到用户数据,请先点「拉取数据」按钮获取。';
static const unlockFetched = '数据已拉取';
static const unlockAutoLogout = '完成后自动退出登录';
static const unlockMusicRun = '执行解锁';
static const unlockRunning = '执行中...';
static const unlockStepFetch = '拉取用户数据';
static const unlockStepUpload = '上传解锁数据';
static const unlockMusicSuccess = '解锁数据上传完成';
// Score form // Collectibles (收藏品获取 / UpsertUserAll 上传收藏品道具)
static const scoreFormTitle = '成绩信息'; // itemKind: 1=姓名框 2=称号 3=头像 10=搭档 11=背景板 12=功能票
static const scoreMusicId = '歌曲 ID (musicId)'; static const collectiblesFeatureTitle = '收藏品获取';
static const scoreLevel = '难度'; static const collectiblesFeatureDesc =
static const scoreAchievement = '达成率 (achievement)'; '通过上传 itemKind 道具获取收藏品(姓名框/称号/头像/搭档/背景板/功能票)。';
static const scoreAchievementHint = '例如 1010000 = 101.0000%'; static const collectiblesItemKindLabel = '收藏品类型 (itemKind)';
static const scoreComboStatus = 'Combo 状态'; static const collectiblesItemIdLabel = '收藏品 ID';
static const scoreSyncStatus = 'Sync 状态'; static const collectiblesItemIdHint = '例如 250103';
static const scoreDeluxscore = 'DX 分 (deluxscoreMax)'; static const collectiblesNeedItemId = '请填写要获取的收藏品 ID。';
static const scorePlayCount = '游玩次数 (playCount)'; static const collectiblesNotLoggedIn = '尚未登录游戏服务器,无法执行获取操作。';
static const scoreRankLabel = '评级 (自动)'; static String collectiblesCooldownNotice(int remaining) =>
static const scoreInvalidInput = '请填写有效的歌曲 ID 与达成率。'; '登录后需冷却 $ticketCooldownSeconds 秒,剩余 $remaining 秒后可执行获取操作。';
static const collectiblesRun = '执行获取';
static const collectiblesStepUpload = '上传获取数据';
static const collectiblesSuccess = '收藏品获取数据上传完成';
static const List<String> levelLabels = [ static const List<int> collectiblesItemKinds = [1, 2, 3, 10, 11, 12];
'Basic (绿)',
'Advanced (黄)',
'Expert (红)',
'Master (紫)',
'Re:Master (白)',
];
static const List<String> comboStatusLabels = [ static String collectiblesItemKindName(int kind) {
'无', switch (kind) {
'FC (Full Combo)', case 1:
'FC+ (Full Combo+)', return '姓名框';
'AP (All Perfect)', case 2:
'AP+ (All Perfect+)', return '称号';
]; case 3:
return '头像';
case 10:
return '搭档';
case 11:
return '背景板';
case 12:
return '功能票';
default:
return '$kind';
}
}
static const List<String> syncStatusLabels = [ // HighRiskFeature hub (中转页)
'无', static const musicRiskHubTitle = '高危功能';
'FS (Full Sync)', static const musicRiskHubDesc = '以下功能会向服务器发送伪造数据,属于高风险操作,请谨慎使用。';
'FS+ (Full Sync+)', static const musicRiskHubNotLoggedIn = '尚未登录游戏服务器,无法执行高危操作。';
'FDX (Full Sync DX)', static String musicRiskHubCooldownNotice(int remaining) =>
'FDX+ (Full Sync DX+)', '登录后需冷却 $ticketCooldownSeconds 秒,剩余 $remaining 秒后可执行高危操作。';
];
static const transferSend = '上传成绩';
static const transferSending = '上传中...';
static const transferSuccess = '成绩上传完成';
static const transferFailed = '成绩上传失败';
static const transferSlotLabel = '槽位';
static const transferNoData = '⚠️ 未拉取到用户数据,请先点「拉取数据」按钮获取。';
// About // About
static const aboutTitle = 'Project Ichino'; static const aboutTitle = 'Purarine';
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 = '构建框架';
+4 -7
View File
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'config/responsive.dart'; import 'config/responsive.dart';
import 'config/strings.dart'; import 'config/strings.dart';
import 'config/title_server_config.dart'; import 'config/title_server_config.dart';
import 'services/api_service.dart'; import 'models/session_model.dart';
import 'services/file_picker_service.dart'; import 'services/file_picker_service.dart';
import 'services/qr_service.dart'; import 'services/qr_service.dart';
import 'pages/home_page.dart'; import 'pages/home_page.dart';
@@ -49,7 +49,6 @@ class LoginPage extends StatefulWidget {
class _LoginPageState extends State<LoginPage> { class _LoginPageState extends State<LoginPage> {
final _qrController = TextEditingController(); final _qrController = TextEditingController();
final _apiService = ApiService();
String _qrCode = ''; String _qrCode = '';
bool _loading = false; bool _loading = false;
bool _forcePreviewApi = false; bool _forcePreviewApi = false;
@@ -62,7 +61,7 @@ class _LoginPageState extends State<LoginPage> {
void _onQRContentChanged(String value) { void _onQRContentChanged(String value) {
setState(() { setState(() {
_qrCode = _apiService.extractQRCode(value.trim()); _qrCode = SessionModel.extractQRCode(value.trim());
}); });
} }
@@ -76,7 +75,7 @@ class _LoginPageState extends State<LoginPage> {
if (result != null && result.isNotEmpty) { if (result != null && result.isNotEmpty) {
_qrController.text = result; _qrController.text = result;
setState(() { setState(() {
_qrCode = _apiService.extractQRCode(result); _qrCode = SessionModel.extractQRCode(result);
}); });
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@@ -105,18 +104,16 @@ class _LoginPageState extends State<LoginPage> {
setState(() => _loading = true); setState(() => _loading = true);
try { try {
final result = await _apiService.login(_qrCode); final result = await SessionModel.instance.loginWithQr(_qrCode);
if (!mounted) return; if (!mounted) return;
if (result.success) { if (result.success) {
if (!mounted) return;
Navigator.of(context).push( Navigator.of(context).push(
MaterialPageRoute( MaterialPageRoute(
builder: (_) => HomePage( builder: (_) => HomePage(
userId: result.userId, userId: result.userId,
token: result.token, token: result.token,
cookies: result.cookies,
forcePreviewApi: _forcePreviewApi, forcePreviewApi: _forcePreviewApi,
), ),
), ),
+90
View File
@@ -0,0 +1,90 @@
import 'package:flutter/foundation.dart';
import '../config/title_server_config.dart';
import '../services/api_service.dart';
import '../services/title_api_service.dart';
class SessionModel extends ChangeNotifier {
static final SessionModel instance = SessionModel._();
SessionModel._();
final ApiService _apiService = ApiService();
LoginResult? _loginResult;
UserLoginResult? _gameLogin;
String? _cookies;
LoginResult? get loginResult => _loginResult;
UserLoginResult? get gameLogin => _gameLogin;
String? get cookies => _cookies;
static String extractQRCode(String qrCodeToken) =>
ApiService.extractQRCode(qrCodeToken);
Future<LoginResult> loginWithQr(String qrCodeToken) async {
final result = await _apiService.login(qrCodeToken);
if (result.success) {
_loginResult = result;
notifyListeners();
}
return result;
}
Future<UserLoginResult> loginGame({
required int userId,
required String token,
}) async {
final config = TitleServerConfigHolder().config;
if (config == null) {
throw const TitleApiException('Title Server 未配置');
}
// Session cookie comes from the UserLoginApi response itself (empurple).
final service = TitleApiService(config);
final login = await service.userLoginFull(userId: userId, token: token);
updateCookies(service.cookies);
_gameLogin = login;
notifyListeners();
return login;
}
Future<void> logoutGame({
required int userId,
String? cookies,
}) async {
final login = _gameLogin;
if (login == null) return;
final config = TitleServerConfigHolder().config;
if (config == null) return;
final service = TitleApiService(config, cookies: cookies ?? _cookies);
try {
await service.userLogout(
userId: userId,
loginDateTime: login.loginDateTime,
);
} catch (_) {
// best-effort
}
_gameLogin = null;
notifyListeners();
}
void setGameLogin(UserLoginResult? login) {
_gameLogin = login;
notifyListeners();
}
void updateCookies(String? cookies) {
if (cookies != null && cookies.isNotEmpty) {
_cookies = cookies;
}
}
void reset() {
_loginResult = null;
_gameLogin = null;
_cookies = null;
notifyListeners();
}
}
-38
View File
@@ -1,38 +0,0 @@
/// The full UpsertUserAll API payload, constructed from fetched user data
/// and an editable single-song score.
class UpsertUserAllPayload {
final int userId;
final int playlogId;
final bool isEventMode;
final bool isFreePlay;
final int loginDateTime;
/// The entire [upsertUserAll] sub-object from the JSON.
final Map<String, dynamic> upsertUserAll;
/// Minimal playlog entry (required — server rejects empty playlogList).
final Map<String, dynamic>? userPlaylog;
const UpsertUserAllPayload({
required this.userId,
required this.playlogId,
this.isEventMode = false,
this.isFreePlay = false,
required this.loginDateTime,
required this.upsertUserAll,
this.userPlaylog,
});
/// Serialize to the JSON packet expected by UpsertUserAllApi.
Map<String, dynamic> toJson() {
return {
'userId': userId,
'playlogId': playlogId,
'isEventMode': isEventMode,
'isFreePlay': isFreePlay,
'loginDateTime': loginDateTime,
'userPlaylogList': userPlaylog != null ? [userPlaylog] : <dynamic>[],
'upsertUserAll': upsertUserAll,
};
}
}
+47
View File
@@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
import '../config/strings.dart';
import 'music_risk_feature_view.dart';
/// 收藏品获取:逻辑参考 `eaquira/action/UnlockMusic.py`,
/// 通过上传 userItemList 道具获取收藏品 (UpsertUserAllApi)。
///
/// itemKind: 1=姓名框 2=称号 3=头像 10=搭档 11=背景板 12=功能票
class CollectiblesPage extends StatelessWidget {
final int userId;
final String? cookies;
final int? loginDateTime;
final int? loginId;
final String? lastLoginDate;
final Future<void> Function()? onLogoutRequested;
const CollectiblesPage({
super.key,
required this.userId,
this.cookies,
this.loginDateTime,
this.loginId,
this.lastLoginDate,
this.onLogoutRequested,
});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text(AppStrings.collectiblesFeatureTitle),
),
body: MusicRiskFeatureView(
userId: userId,
cookies: cookies,
loginDateTime: loginDateTime,
loginId: loginId,
lastLoginDate: lastLoginDate,
onLogoutRequested: onLogoutRequested,
mode: MusicRiskFeatureMode.collectibles,
featureTitle: AppStrings.collectiblesFeatureTitle,
featureDesc: AppStrings.collectiblesFeatureDesc,
),
);
}
}
+1
View File
@@ -54,6 +54,7 @@ class ConfigExportImportPage extends StatelessWidget {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text(AppStrings.importSuccess)), const SnackBar(content: Text(AppStrings.importSuccess)),
); );
Navigator.of(context).pop();
} }
} catch (_) { } catch (_) {
if (context.mounted) { if (context.mounted) {
+327
View File
@@ -0,0 +1,327 @@
import 'dart:async';
import 'package:flutter/material.dart';
import '../config/responsive.dart';
import '../config/strings.dart';
import '../config/title_server_config.dart';
import 'collectibles_page.dart';
import 'unlock_music_page.dart';
/// 高危功能中转页:导航到 UnlockMusic (解锁歌曲) / 收藏品获取。
/// 两个 feature 互相独立,各自的执行流程互不影响。
class HighRiskFeaturePage extends StatefulWidget {
final int userId;
final String? cookies;
/// `loginDateTime` 来自 HomePage 启动时执行的 UserLoginApi。
/// 为 `null` 时表示当前未持有有效登录态。
final int? loginDateTime;
/// UserLoginApi 返回的 loginId / lastLoginDate, 组装 UserAll 需要。
final int? loginId;
final String? lastLoginDate;
/// 由 HomePage 负责发送 logout 包。
final Future<void> Function()? onLogoutRequested;
const HighRiskFeaturePage({
super.key,
required this.userId,
this.cookies,
this.loginDateTime,
this.loginId,
this.lastLoginDate,
this.onLogoutRequested,
});
@override
State<HighRiskFeaturePage> createState() => _HighRiskFeaturePageState();
}
class _HighRiskFeaturePageState extends State<HighRiskFeaturePage> {
Timer? _cooldownTimer;
int _cooldownRemaining = 0;
@override
void initState() {
super.initState();
_syncCooldown();
}
@override
void didUpdateWidget(covariant HighRiskFeaturePage oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.loginDateTime != widget.loginDateTime) {
_syncCooldown();
}
}
@override
void dispose() {
_cooldownTimer?.cancel();
super.dispose();
}
void _syncCooldown() {
_cooldownTimer?.cancel();
final loginDateTime = widget.loginDateTime;
if (loginDateTime == null) {
_cooldownRemaining = 0;
return;
}
_cooldownRemaining = _computeRemaining(loginDateTime);
if (_cooldownRemaining <= 0) return;
_cooldownTimer = Timer.periodic(const Duration(seconds: 1), (_) {
if (!mounted) return;
final remaining = _computeRemaining(loginDateTime);
setState(() => _cooldownRemaining = remaining);
if (remaining <= 0) {
_cooldownTimer?.cancel();
_cooldownTimer = null;
}
});
}
int _computeRemaining(int loginDateTime) {
final nowSec = DateTime.now().millisecondsSinceEpoch ~/ 1000;
final elapsed = nowSec - loginDateTime;
final remaining = AppStrings.ticketCooldownSeconds - elapsed;
return remaining < 0 ? 0 : remaining;
}
void _openUnlockMusic() {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => UnlockMusicPage(
userId: widget.userId,
cookies: widget.cookies,
loginDateTime: widget.loginDateTime,
loginId: widget.loginId,
lastLoginDate: widget.lastLoginDate,
onLogoutRequested: widget.onLogoutRequested,
),
),
);
}
void _openCollectibles() {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => CollectiblesPage(
userId: widget.userId,
cookies: widget.cookies,
loginDateTime: widget.loginDateTime,
loginId: widget.loginId,
lastLoginDate: widget.lastLoginDate,
onLogoutRequested: widget.onLogoutRequested,
),
),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
if (!TitleServerConfigHolder().isConfigured) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.settings_ethernet,
size: 48, color: theme.colorScheme.primary),
const SizedBox(height: 16),
Text(AppStrings.ticketNotConfigured,
style: theme.textTheme.titleMedium),
],
),
),
);
}
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: responsiveMaxWidth(context)),
child: Column(
children: [
if (widget.loginDateTime == null) _buildNotLoggedInBanner(theme),
if (widget.loginDateTime != null && _cooldownRemaining > 0)
_buildCooldownBanner(theme),
_buildDescCard(theme),
const SizedBox(height: 12),
_buildNavCard(
theme,
icon: Icons.lock_open,
title: AppStrings.unlockFeatureTitle,
desc: AppStrings.unlockFeatureDesc,
onTap: _openUnlockMusic,
),
const SizedBox(height: 12),
_buildNavCard(
theme,
icon: Icons.card_giftcard,
title: AppStrings.collectiblesFeatureTitle,
desc: AppStrings.collectiblesFeatureDesc,
onTap: _openCollectibles,
),
],
),
),
);
}
Widget _buildNotLoggedInBanner(ThemeData theme) {
return Container(
width: double.infinity,
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: theme.colorScheme.errorContainer,
borderRadius: BorderRadius.circular(10),
),
child: Row(
children: [
Icon(Icons.warning_amber_rounded,
size: 18, color: theme.colorScheme.onErrorContainer),
const SizedBox(width: 8),
Expanded(
child: Text(
AppStrings.musicRiskHubNotLoggedIn,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onErrorContainer,
),
),
),
],
),
);
}
Widget _buildCooldownBanner(ThemeData theme) {
return Container(
width: double.infinity,
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: theme.colorScheme.tertiaryContainer.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(10),
),
child: Row(
children: [
Icon(Icons.timer_outlined,
size: 18, color: theme.colorScheme.onTertiaryContainer),
const SizedBox(width: 8),
Expanded(
child: Text(
AppStrings.musicRiskHubCooldownNotice(_cooldownRemaining),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onTertiaryContainer,
),
),
),
],
),
);
}
Widget _buildDescCard(ThemeData theme) {
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: theme.colorScheme.error.withValues(alpha: 0.4),
),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.warning_amber_rounded,
size: 18, color: theme.colorScheme.error),
const SizedBox(width: 8),
Expanded(
child: Text(
AppStrings.musicRiskHubTitle,
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.error,
fontWeight: FontWeight.w600,
),
),
),
],
),
const SizedBox(height: 12),
Text(
AppStrings.musicRiskHubDesc,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
);
}
Widget _buildNavCard(
ThemeData theme, {
required IconData icon,
required String title,
required String desc,
required VoidCallback onTap,
}) {
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: theme.colorScheme.outline.withValues(alpha: 0.3)),
),
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(20),
child: Row(
children: [
Icon(icon, size: 28, color: theme.colorScheme.primary),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
Text(
desc,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
const SizedBox(width: 8),
Icon(
Icons.chevron_right,
color: theme.colorScheme.onSurfaceVariant,
),
],
),
),
),
);
}
}
+87 -96
View File
@@ -3,26 +3,25 @@ import 'package:flutter/material.dart';
import '../config/responsive.dart'; import '../config/responsive.dart';
import '../config/strings.dart'; import '../config/strings.dart';
import '../config/title_server_config.dart'; import '../config/title_server_config.dart';
import '../models/session_model.dart';
import '../models/user_data.dart'; 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 'high_risk_feature_page.dart';
import 'settings_page.dart'; import 'settings_page.dart';
import 'ticket_page.dart'; import 'ticket_page.dart';
import 'transfer_package_page.dart';
class HomePage extends StatefulWidget { class HomePage extends StatefulWidget {
final int userId; final int userId;
final String token; final String token;
final String? cookies;
final bool forcePreviewApi; final bool forcePreviewApi;
const HomePage({ const HomePage({
super.key, super.key,
required this.userId, required this.userId,
required this.token, required this.token,
this.cookies,
this.forcePreviewApi = false, this.forcePreviewApi = false,
}); });
@@ -34,16 +33,16 @@ class _HomePageState extends State<HomePage> {
int _currentTab = 0; int _currentTab = 0;
UserPreviewDataBean? _preview; UserPreviewDataBean? _preview;
UserDataBean? _userData; UserDataBean? _userData;
UserLoginResult? _login;
String? _error; String? _error;
bool _loading = true; bool _loading = true;
bool _loggingOut = false; bool _loggingOut = false;
String? _sessionCookies;
SessionModel get _session => SessionModel.instance;
static const _tabTitles = [ static const _tabTitles = [
AppStrings.tabHome, AppStrings.tabHome,
AppStrings.tabTickets, AppStrings.tabTickets,
AppStrings.tabTransfer, AppStrings.tabRisk,
AppStrings.tabSettings, AppStrings.tabSettings,
AppStrings.tabAbout, AppStrings.tabAbout,
]; ];
@@ -68,12 +67,12 @@ class _HomePageState extends State<HomePage> {
_error = null; _error = null;
_preview = null; _preview = null;
_userData = null; _userData = null;
_login = null;
}); });
_session.setGameLogin(null);
final service = TitleApiService( final service = TitleApiService(
TitleServerConfigHolder().config!, TitleServerConfigHolder().config!,
cookies: widget.cookies, cookies: _session.cookies,
); );
try { try {
@@ -82,7 +81,7 @@ class _HomePageState extends State<HomePage> {
token: widget.token, token: widget.token,
); );
if (!mounted) return; if (!mounted) return;
_sessionCookies = service.cookies; _session.updateCookies(service.cookies);
setState(() => _preview = preview); setState(() => _preview = preview);
// isLogin == true => 已有人登录, 不再走完整登录, 直接展示 preview 概要 // isLogin == true => 已有人登录, 不再走完整登录, 直接展示 preview 概要
@@ -92,17 +91,19 @@ class _HomePageState extends State<HomePage> {
return; return;
} }
final login = await service.userLoginFull( await _session.loginGame(
userId: widget.userId, userId: widget.userId,
token: widget.token, token: widget.token,
); );
if (!mounted) return; if (!mounted) return;
_sessionCookies = service.cookies;
setState(() => _login = login);
final userData = await service.getUserDataTyped(widget.userId); final userDataService = TitleApiService(
TitleServerConfigHolder().config!,
cookies: _session.cookies,
);
final userData = await userDataService.getUserDataTyped(widget.userId);
if (!mounted) return; if (!mounted) return;
_sessionCookies = service.cookies; _session.updateCookies(userDataService.cookies);
setState(() { setState(() {
_userData = userData; _userData = userData;
_loading = false; _loading = false;
@@ -129,23 +130,20 @@ class _HomePageState extends State<HomePage> {
_error = null; _error = null;
}); });
final service = TitleApiService(
TitleServerConfigHolder().config!,
cookies: _sessionCookies ?? widget.cookies,
);
try { try {
final login = await service.userLoginFull( await _session.loginGame(
userId: widget.userId, userId: widget.userId,
token: widget.token, token: widget.token,
); );
if (!mounted) return; if (!mounted) return;
_sessionCookies = service.cookies;
setState(() => _login = login);
final service = TitleApiService(
TitleServerConfigHolder().config!,
cookies: _session.cookies,
);
final userData = await service.getUserDataTyped(widget.userId); final userData = await service.getUserDataTyped(widget.userId);
if (!mounted) return; if (!mounted) return;
_sessionCookies = service.cookies; _session.updateCookies(service.cookies);
setState(() { setState(() {
_userData = userData; _userData = userData;
_loading = false; _loading = false;
@@ -166,30 +164,16 @@ class _HomePageState extends State<HomePage> {
} }
Future<void> _logoutSession() async { Future<void> _logoutSession() async {
final login = _login; await _session.logoutGame(userId: widget.userId);
if (login == null) return;
if (!TitleServerConfigHolder().isConfigured) return;
final service = TitleApiService(
TitleServerConfigHolder().config!,
cookies: _sessionCookies ?? widget.cookies,
);
try {
await service.userLogout(
userId: widget.userId,
loginDateTime: login.loginDateTime,
);
} catch (_) {
// best-effort
}
_login = null;
} }
Future<void> _performLogoutAndExit() async { Future<void> _performLogoutAndExit() async {
setState(() => _loggingOut = true); setState(() => _loggingOut = true);
if (_login != null) { if (_session.gameLogin != null) {
await Future.delayed(const Duration(seconds: 5)); await Future.delayed(const Duration(seconds: 5));
await _logoutSession(); await _logoutSession();
} }
_session.reset();
if (mounted) { if (mounted) {
Navigator.of(context).pop(); Navigator.of(context).pop();
} }
@@ -199,61 +183,68 @@ class _HomePageState extends State<HomePage> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
return PopScope( return ListenableBuilder(
canPop: !_loggingOut, listenable: _session,
onPopInvokedWithResult: (didPop, _) { builder: (context, _) {
if (!didPop && !_loggingOut) { return PopScope(
_performLogoutAndExit(); canPop: !_loggingOut,
} onPopInvokedWithResult: (didPop, _) {
if (!didPop && !_loggingOut) {
_performLogoutAndExit();
}
},
child: Scaffold(
appBar: AppBar(
leading: IconButton(
icon: _loggingOut
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.arrow_back),
onPressed: _loggingOut ? null : _performLogoutAndExit,
),
title: Text(_tabTitles[_currentTab]),
),
body: IndexedStack(
index: _currentTab,
children: [
_buildBody(theme),
TicketPage(
userId: widget.userId,
cookies: _session.cookies,
loginDateTime: _session.gameLogin?.loginDateTime,
playerRating: _userData?.playerRating ?? _preview?.playerRating ?? 0,
onLogoutRequested: _session.gameLogin != null ? _logoutSession : null,
),
HighRiskFeaturePage(
userId: widget.userId,
cookies: _session.cookies,
loginDateTime: _session.gameLogin?.loginDateTime,
loginId: _session.gameLogin?.loginId,
lastLoginDate: _session.gameLogin?.lastLoginDate,
onLogoutRequested:
_session.gameLogin != null ? _logoutSession : null,
),
SettingsPage(showAppBar: false),
const AboutPage(),
],
),
bottomNavigationBar: NavigationBar(
selectedIndex: _currentTab,
onDestinationSelected: (i) => setState(() => _currentTab = i),
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.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),
],
),
),
);
}, },
child: Scaffold(
appBar: AppBar(
leading: IconButton(
icon: _loggingOut
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.arrow_back),
onPressed: _loggingOut ? null : _performLogoutAndExit,
),
title: Text(_tabTitles[_currentTab]),
),
body: IndexedStack(
index: _currentTab,
children: [
_buildBody(theme),
TicketPage(
userId: widget.userId,
cookies: _sessionCookies ?? widget.cookies,
loginDateTime: _login?.loginDateTime,
playerRating: _userData?.playerRating ?? _preview?.playerRating ?? 0,
onLogoutRequested: _login != null ? _logoutSession : null,
),
TransferPackagePage(
userId: widget.userId,
cookies: _sessionCookies ?? widget.cookies,
loginDateTime: _login?.loginDateTime,
playlogId: _login?.loginId,
lastLoginDate: _login?.lastLoginDate,
),
SettingsPage(showAppBar: false),
const AboutPage(),
],
),
bottomNavigationBar: NavigationBar(
selectedIndex: _currentTab,
onDestinationSelected: (i) => setState(() => _currentTab = i),
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.inventory_2_outlined), selectedIcon: Icon(Icons.inventory_2), label: AppStrings.tabTransfer),
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),
],
),
),
); );
} }
@@ -332,11 +323,11 @@ class _HomePageState extends State<HomePage> {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
if ((preview.isLogin || (widget.forcePreviewApi && _login == null)) && userData == null) { if ((preview.isLogin || (widget.forcePreviewApi && _session.gameLogin == null)) && userData == null) {
return _buildPreviewOnly( return _buildPreviewOnly(
theme, theme,
preview, preview,
showLoginButton: widget.forcePreviewApi && _login == null, showLoginButton: widget.forcePreviewApi && _session.gameLogin == null,
onLoginPressed: _performLogin, onLoginPressed: _performLogin,
); );
} }
+914
View File
@@ -0,0 +1,914 @@
import 'dart:async';
import 'package:flutter/material.dart';
import '../config/responsive.dart';
import '../config/strings.dart';
import '../config/title_server_config.dart';
import '../services/title_api_service.dart';
import '../services/user_all_payload_builder.dart';
/// Dart port of `eaquira/action/UnlockMusic.py`:
///
/// - 拉取 GetUserData/Extend/Option/Rating/Charge/Activity/MissionData
/// - 组装 UserAll payload (musicData 使用 eaquira settings 中的默认值)
/// - 附加道具 (userItemList / isNewItemList)
/// - UpsertUserAllApi 上传
/// - 可选自动 UserLogout
enum MusicRiskStep { idle, fetchData, upload, logout, complete, failed }
enum MusicRiskFeatureMode {
/// 解锁歌曲与谱面 (itemKind 5/6/7)
unlockMusic,
/// 收藏品获取 (itemKind 1/2/3/10/11/12)
collectibles,
}
class MusicRiskFeatureView extends StatefulWidget {
final int userId;
final String? cookies;
/// `loginDateTime` 来自 HomePage 启动时执行的 UserLoginApi。
/// 为 `null` 时表示当前未持有有效登录态(例如 isInherit 账号或登录失败)。
final int? loginDateTime;
/// UserLoginApi 返回的 loginId / lastLoginDate, 组装 UserAll 需要。
final int? loginId;
final String? lastLoginDate;
/// 当用户勾选"完成后自动退出登录"时,由 HomePage 负责发送 logout 包。
final Future<void> Function()? onLogoutRequested;
final MusicRiskFeatureMode mode;
final String featureTitle;
final String featureDesc;
const MusicRiskFeatureView({
super.key,
required this.userId,
this.cookies,
this.loginDateTime,
this.loginId,
this.lastLoginDate,
this.onLogoutRequested,
this.mode = MusicRiskFeatureMode.unlockMusic,
required this.featureTitle,
required this.featureDesc,
});
@override
State<MusicRiskFeatureView> createState() => _MusicRiskFeatureViewState();
}
class _MusicRiskFeatureViewState extends State<MusicRiskFeatureView> {
/// eaquira `settings.musicData` 默认歌曲 (Amber Chronicle)。
static const int _defaultMusicId = 11538;
// ── 解锁选项 (UnlockMusic) ──
final _unlockMusicIdController = TextEditingController();
bool _unlockMusic = false;
bool _unlockMaster = false;
bool _unlockRemaster = false;
// ── 收藏品选项 (Collectibles) ──
final _itemIdController = TextEditingController();
int _itemKind = 3;
// ── 运行状态 ──
Map<String, Map<String, dynamic>>? _userAllData;
bool _fetching = false;
String? _fetchError;
bool _running = false;
bool _autoLogout = false;
MusicRiskStep _step = MusicRiskStep.idle;
String _stepMessage = '';
String? _error;
// ── 冷却 ──
Timer? _cooldownTimer;
int _cooldownRemaining = 0;
bool get _isUnlock => widget.mode == MusicRiskFeatureMode.unlockMusic;
@override
void initState() {
super.initState();
_syncCooldown();
}
@override
void didUpdateWidget(covariant MusicRiskFeatureView oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.loginDateTime != widget.loginDateTime) {
_syncCooldown();
}
}
@override
void dispose() {
_cooldownTimer?.cancel();
_unlockMusicIdController.dispose();
_itemIdController.dispose();
super.dispose();
}
void _syncCooldown() {
_cooldownTimer?.cancel();
final loginDateTime = widget.loginDateTime;
if (loginDateTime == null) {
_cooldownRemaining = 0;
return;
}
_cooldownRemaining = _computeRemaining(loginDateTime);
if (_cooldownRemaining <= 0) return;
_cooldownTimer = Timer.periodic(const Duration(seconds: 1), (_) {
if (!mounted) return;
final remaining = _computeRemaining(loginDateTime);
setState(() => _cooldownRemaining = remaining);
if (remaining <= 0) {
_cooldownTimer?.cancel();
_cooldownTimer = null;
}
});
}
int _computeRemaining(int loginDateTime) {
final nowSec = DateTime.now().millisecondsSinceEpoch ~/ 1000;
final elapsed = nowSec - loginDateTime;
final remaining = AppStrings.ticketCooldownSeconds - elapsed;
return remaining < 0 ? 0 : remaining;
}
void _updateStep(MusicRiskStep step, [String? message]) {
if (!mounted) return;
setState(() {
_step = step;
_stepMessage = message ?? '';
});
}
int _unlockMusicId() =>
int.tryParse(_unlockMusicIdController.text.trim()) ?? -1;
int _itemId() => int.tryParse(_itemIdController.text.trim()) ?? -1;
/// eaquira UnlockMusic.py 使用 settings 中的默认 musicData。
Map<String, dynamic> _buildMusicData() {
return {
'musicId': _isUnlock ? _unlockMusicId() : _defaultMusicId,
'level': 0,
'playCount': 1,
'achievement': 0,
'comboStatus': 0,
'syncStatus': 0,
'deluxscoreMax': 0,
'scoreRank': 0,
'extNum1': 0,
};
}
String? _validateInputs() {
if (_isUnlock) {
if (_unlockMusicId() <= 0) return AppStrings.unlockNeedMusicId;
if (!_unlockMusic && !_unlockMaster && !_unlockRemaster) {
return AppStrings.unlockNeedOption;
}
return null;
}
if (_itemId() <= 0) return AppStrings.collectiblesNeedItemId;
return null;
}
Future<void> _fetchData() async {
if (!TitleServerConfigHolder().isConfigured) return;
setState(() {
_fetching = true;
_fetchError = null;
_userAllData = null;
});
try {
final config = TitleServerConfigHolder().config!;
final service = TitleApiService(config, cookies: widget.cookies);
final data = await service.fetchUserAllData(widget.userId);
if (!mounted) return;
setState(() {
_userAllData = data;
_fetching = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_fetchError = e.toString();
_fetching = false;
});
}
}
Future<void> _run() async {
final validationError = _validateInputs();
if (validationError != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(validationError)),
);
return;
}
if (!TitleServerConfigHolder().isConfigured) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text(AppStrings.ticketNotConfigured)),
);
return;
}
final loginDateTime = widget.loginDateTime;
final loginId = widget.loginId;
if (loginDateTime == null || loginId == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(_notLoggedInMessage)),
);
return;
}
if (_cooldownRemaining > 0) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(_cooldownNotice(_cooldownRemaining))),
);
return;
}
final config = TitleServerConfigHolder().config!;
final service = TitleApiService(config, cookies: widget.cookies);
setState(() {
_running = true;
_error = null;
});
try {
Map<String, Map<String, dynamic>> data = _userAllData ?? const {};
if (data.isEmpty) {
_updateStep(MusicRiskStep.fetchData);
data = await service.fetchUserAllData(widget.userId);
if (mounted) setState(() => _userAllData = data);
}
_updateStep(MusicRiskStep.upload);
final musicData = _buildMusicData();
final builder = UserAllPayloadBuilder(config);
final packet = builder.build(
userId: widget.userId,
loginId: loginId,
loginDateTime: loginDateTime,
musicData: musicData,
generalUserInfo: data,
);
if (_isUnlock) {
builder.applyMusicUnlockPatch(
packet,
musicData: musicData,
musicId: _unlockMusicId(),
unlockMusic: _unlockMusic,
unlockMaster: _unlockMaster,
unlockRemaster: _unlockRemaster,
);
} else {
builder.applyItemListPatch(packet, items: [
{'itemKind': _itemKind, 'itemId': _itemId(), 'stock': 1, 'isValid': true},
]);
}
await service.upsertUserAll(packet, widget.userId);
if (_autoLogout && widget.onLogoutRequested != null) {
_updateStep(MusicRiskStep.logout);
await Future.delayed(const Duration(seconds: 5));
await widget.onLogoutRequested!();
}
_updateStep(
MusicRiskStep.complete,
_isUnlock
? AppStrings.unlockMusicSuccess
: AppStrings.collectiblesSuccess,
);
} on TitleApiException catch (e) {
_updateStep(MusicRiskStep.failed, e.message);
setState(() => _error = e.message);
} catch (e) {
_updateStep(MusicRiskStep.failed, e.toString());
setState(() => _error = e.toString());
} finally {
if (mounted) setState(() => _running = false);
}
}
String get _notLoggedInMessage => _isUnlock
? AppStrings.unlockNotLoggedIn
: AppStrings.collectiblesNotLoggedIn;
String _cooldownNotice(int remaining) => _isUnlock
? AppStrings.unlockCooldownNotice(remaining)
: AppStrings.collectiblesCooldownNotice(remaining);
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
if (!TitleServerConfigHolder().isConfigured) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.settings_ethernet,
size: 48, color: theme.colorScheme.primary),
const SizedBox(height: 16),
Text(AppStrings.ticketNotConfigured,
style: theme.textTheme.titleMedium),
],
),
),
);
}
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: responsiveMaxWidth(context)),
child: Column(
children: [
if (widget.loginDateTime == null) _buildNotLoggedInBanner(theme),
if (widget.loginDateTime != null && _cooldownRemaining > 0)
_buildCooldownBanner(theme),
_buildDescCard(theme),
const SizedBox(height: 12),
if (_isUnlock)
_buildUnlockCard(theme)
else
_buildCollectiblesCard(theme),
const SizedBox(height: 12),
_buildFetchCard(theme),
const SizedBox(height: 12),
_buildAutoLogoutToggle(theme),
const SizedBox(height: 12),
_buildRunButton(theme),
if (_step != MusicRiskStep.idle) ...[
const SizedBox(height: 16),
_buildProgressCard(theme),
],
],
),
),
);
}
Widget _buildNotLoggedInBanner(ThemeData theme) {
return Container(
width: double.infinity,
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: theme.colorScheme.errorContainer,
borderRadius: BorderRadius.circular(10),
),
child: Row(
children: [
Icon(Icons.warning_amber_rounded,
size: 18, color: theme.colorScheme.onErrorContainer),
const SizedBox(width: 8),
Expanded(
child: Text(
_notLoggedInMessage,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onErrorContainer,
),
),
),
],
),
);
}
Widget _buildCooldownBanner(ThemeData theme) {
return Container(
width: double.infinity,
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: theme.colorScheme.tertiaryContainer.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(10),
),
child: Row(
children: [
Icon(Icons.timer_outlined,
size: 18, color: theme.colorScheme.onTertiaryContainer),
const SizedBox(width: 8),
Expanded(
child: Text(
_cooldownNotice(_cooldownRemaining),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onTertiaryContainer,
),
),
),
],
),
);
}
Widget _buildDescCard(ThemeData theme) {
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: theme.colorScheme.outline.withValues(alpha: 0.3)),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
_isUnlock ? Icons.lock_open : Icons.card_giftcard,
size: 18,
color: theme.colorScheme.primary,
),
const SizedBox(width: 8),
Expanded(
child: Text(
widget.featureTitle,
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
),
],
),
const SizedBox(height: 12),
Text(
widget.featureDesc,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
);
}
Widget _buildUnlockCard(ThemeData theme) {
final enabled = !_running;
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: theme.colorScheme.outline.withValues(alpha: 0.3)),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: _unlockMusicIdController,
enabled: enabled,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: AppStrings.unlockMusicIdLabel,
hintText: AppStrings.unlockMusicIdHint,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
),
contentPadding: const EdgeInsets.all(14),
),
),
const SizedBox(height: 6),
_buildUnlockCheckbox(
theme,
value: _unlockMusic,
label: AppStrings.unlockMusicOption,
enabled: enabled,
onChanged: (v) => setState(() => _unlockMusic = v),
),
_buildUnlockCheckbox(
theme,
value: _unlockMaster,
label: AppStrings.unlockMasterOption,
enabled: enabled,
onChanged: (v) => setState(() => _unlockMaster = v),
),
_buildUnlockCheckbox(
theme,
value: _unlockRemaster,
label: AppStrings.unlockRemasterOption,
enabled: enabled,
onChanged: (v) => setState(() => _unlockRemaster = v),
),
],
),
),
);
}
Widget _buildUnlockCheckbox(
ThemeData theme, {
required bool value,
required String label,
required bool enabled,
required ValueChanged<bool> onChanged,
}) {
return InkWell(
borderRadius: BorderRadius.circular(10),
onTap: enabled ? () => onChanged(!value) : null,
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
child: Row(
children: [
Checkbox(
value: value,
onChanged: enabled ? (v) => onChanged(v ?? false) : null,
),
Expanded(
child: Text(
label,
style: theme.textTheme.bodyMedium?.copyWith(
color: enabled
? null
: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
),
),
),
],
),
),
);
}
Widget _buildCollectiblesCard(ThemeData theme) {
final enabled = !_running;
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: theme.colorScheme.outline.withValues(alpha: 0.3)),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildDropdown(
value: _itemKind,
label: AppStrings.collectiblesItemKindLabel,
items: [
for (final kind in AppStrings.collectiblesItemKinds)
DropdownMenuItem(
value: kind,
child: Text(
'${AppStrings.collectiblesItemKindName(kind)} ($kind)',
),
),
],
onChanged: (v) => setState(() => _itemKind = v),
),
const SizedBox(height: 12),
TextField(
controller: _itemIdController,
enabled: enabled,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: AppStrings.collectiblesItemIdLabel,
hintText: AppStrings.collectiblesItemIdHint,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
),
contentPadding: const EdgeInsets.all(14),
),
),
],
),
),
);
}
Widget _buildFetchCard(ThemeData theme) {
final hasData = _userAllData != null;
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: hasData
? Colors.green.withValues(alpha: 0.5)
: theme.colorScheme.outline.withValues(alpha: 0.3),
),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
hasData ? Icons.check_circle : Icons.download,
size: 18,
color: hasData ? Colors.green : theme.colorScheme.primary,
),
const SizedBox(width: 8),
Expanded(
child: Text(
hasData
? AppStrings.unlockFetched
: AppStrings.unlockNoData,
style: theme.textTheme.bodySmall?.copyWith(
color: hasData
? Colors.green
: theme.colorScheme.onSurfaceVariant,
),
),
),
SizedBox(
height: 32,
child: OutlinedButton.icon(
onPressed: _fetching || _running ? null : _fetchData,
icon: _fetching
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.refresh, size: 16),
label: Text(
_fetching
? AppStrings.unlockFetching
: hasData
? AppStrings.unlockRefetch
: AppStrings.unlockFetchData,
style: theme.textTheme.labelSmall,
),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
),
],
),
if (_fetchError != null) ...[
const SizedBox(height: 12),
Container(
width: double.infinity,
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: theme.colorScheme.errorContainer,
borderRadius: BorderRadius.circular(8),
),
child: Text(
_fetchError!,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onErrorContainer,
fontFamily: 'monospace',
),
),
),
],
],
),
),
);
}
Widget _buildAutoLogoutToggle(ThemeData theme) {
final enabled = widget.onLogoutRequested != null && !_running;
return InkWell(
borderRadius: BorderRadius.circular(10),
onTap: enabled
? () => setState(() => _autoLogout = !_autoLogout)
: null,
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: Row(
children: [
Checkbox(
value: _autoLogout,
onChanged: enabled
? (v) => setState(() => _autoLogout = v ?? false)
: null,
),
Expanded(
child: Text(
AppStrings.unlockAutoLogout,
style: theme.textTheme.bodyMedium?.copyWith(
color: enabled
? null
: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
),
),
),
],
),
),
);
}
Widget _buildRunButton(ThemeData theme) {
final onCooldown = _cooldownRemaining > 0;
final canRun = widget.loginDateTime != null &&
widget.loginId != null &&
!_running &&
!onCooldown;
return SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: canRun ? _run : null,
icon: _running
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
)
: Icon(
onCooldown
? Icons.timer_outlined
: (_isUnlock ? Icons.lock_open : Icons.card_giftcard),
size: 20,
),
label: Text(
_running
? AppStrings.unlockRunning
: onCooldown
? AppStrings.ticketCooldownCountdown(_cooldownRemaining)
: _isUnlock
? AppStrings.unlockMusicRun
: AppStrings.collectiblesRun,
),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
);
}
Widget _buildProgressCard(ThemeData theme) {
final isFailed = _step == MusicRiskStep.failed;
final isDone = _step == MusicRiskStep.complete;
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: isFailed
? theme.colorScheme.error.withValues(alpha: 0.5)
: isDone
? Colors.green.withValues(alpha: 0.5)
: theme.colorScheme.outline.withValues(alpha: 0.3),
),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_stepRow(MusicRiskStep.fetchData, AppStrings.unlockStepFetch, theme),
_stepRow(
MusicRiskStep.upload,
_isUnlock
? AppStrings.unlockStepUpload
: AppStrings.collectiblesStepUpload,
theme,
),
if (_autoLogout)
_stepRow(MusicRiskStep.logout, AppStrings.stepLogout, theme),
if (_stepMessage.isNotEmpty) ...[
const SizedBox(height: 12),
Container(
width: double.infinity,
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: isFailed
? theme.colorScheme.errorContainer
: isDone
? Colors.green.withValues(alpha: 0.1)
: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Text(
_stepMessage,
style: theme.textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
color: isFailed
? theme.colorScheme.onErrorContainer
: theme.colorScheme.onSurface,
),
),
),
],
if (_error != null) ...[
const SizedBox(height: 12),
Container(
width: double.infinity,
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: theme.colorScheme.errorContainer,
borderRadius: BorderRadius.circular(8),
),
child: Text(
_error!,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onErrorContainer,
fontFamily: 'monospace',
),
),
),
],
],
),
),
);
}
Widget _stepRow(MusicRiskStep step, String label, ThemeData theme) {
IconData icon;
Color? color;
if (_step == MusicRiskStep.failed && _step.index <= step.index) {
icon = _step == step ? Icons.error : Icons.circle_outlined;
color = _step == step
? theme.colorScheme.error
: theme.colorScheme.onSurfaceVariant;
} else if (_step.index > step.index) {
icon = Icons.check_circle;
color = Colors.green;
} else if (_step == step) {
icon = Icons.sync;
color = theme.colorScheme.primary;
} else {
icon = Icons.circle_outlined;
color = theme.colorScheme.onSurfaceVariant;
}
return Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: Row(
children: [
Icon(icon, size: 16, color: color),
const SizedBox(width: 10),
Text(
label,
style: theme.textTheme.bodySmall?.copyWith(
color: _step.index >= step.index
? theme.colorScheme.onSurface
: theme.colorScheme.onSurfaceVariant,
fontWeight: _step == step ? FontWeight.w600 : FontWeight.normal,
),
),
],
),
);
}
Widget _buildDropdown({
required int value,
required String label,
required List<DropdownMenuItem<int>> items,
required ValueChanged<int> onChanged,
}) {
return DropdownButtonFormField<int>(
initialValue: value,
decoration: InputDecoration(
labelText: label,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
),
contentPadding: const EdgeInsets.all(14),
),
items: items,
onChanged: _running
? null
: (v) {
if (v == null) return;
onChanged(v);
},
);
}
}
+9 -2
View File
@@ -33,6 +33,10 @@ class _SettingsPageState extends State<SettingsPage> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_loadConfig();
}
void _loadConfig() {
final config = TitleServerConfigHolder().config; final config = TitleServerConfigHolder().config;
if (config != null) { if (config != null) {
_urlController.text = config.titleServerUrl; _urlController.text = config.titleServerUrl;
@@ -155,10 +159,13 @@ class _SettingsPageState extends State<SettingsPage> {
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
OutlinedButton.icon( OutlinedButton.icon(
onPressed: () { onPressed: () async {
Navigator.of(context).push( await Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const ConfigExportImportPage()), MaterialPageRoute(builder: (_) => const ConfigExportImportPage()),
); );
if (mounted) {
_loadConfig();
}
}, },
icon: const Icon(Icons.import_export, size: 20), icon: const Icon(Icons.import_export, size: 20),
label: const Text(AppStrings.configExportImport), label: const Text(AppStrings.configExportImport),
+13 -4
View File
@@ -42,16 +42,16 @@ class TicketPage extends StatefulWidget {
class _TicketPageState extends State<TicketPage> { class _TicketPageState extends State<TicketPage> {
static const _ticketNameMap = { static const _ticketNameMap = {
2: '2倍功能票', 2: '2倍票',
4: '4倍功能票', 4: '4倍票',
5: '5倍功能票' 5: '5倍票'
}; };
TicketStep _step = TicketStep.idle; TicketStep _step = TicketStep.idle;
String _stepMessage = ''; String _stepMessage = '';
String? _error; String? _error;
bool _running = false; bool _running = false;
bool _autoLogout = false; bool _autoLogout = true;
List<Map<String, dynamic>>? _tickets; List<Map<String, dynamic>>? _tickets;
bool _ticketsLoading = false; bool _ticketsLoading = false;
@@ -187,6 +187,15 @@ class _TicketPageState extends State<TicketPage> {
return; return;
} }
final selectedTicket = _findTicket(_selectedTicketId!);
if (selectedTicket != null &&
(selectedTicket['stock'] as num?)?.toInt() != 0) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text(AppStrings.ticketStockNotEmpty)),
);
return;
}
final config = TitleServerConfigHolder().config!; final config = TitleServerConfigHolder().config!;
final service = TitleApiService(config, cookies: widget.cookies); final service = TitleApiService(config, cookies: widget.cookies);
final ticketId = _selectedTicketId!; final ticketId = _selectedTicketId!;
File diff suppressed because it is too large. Load diff
+45
View File
@@ -0,0 +1,45 @@
import 'package:flutter/material.dart';
import '../config/strings.dart';
import 'music_risk_feature_view.dart';
/// Port of `eaquira/action/UnlockMusic.py`:
/// 上传 itemKind 5/6/7 道具解锁歌曲与谱面 (UpsertUserAllApi)。
class UnlockMusicPage extends StatelessWidget {
final int userId;
final String? cookies;
final int? loginDateTime;
final int? loginId;
final String? lastLoginDate;
final Future<void> Function()? onLogoutRequested;
const UnlockMusicPage({
super.key,
required this.userId,
this.cookies,
this.loginDateTime,
this.loginId,
this.lastLoginDate,
this.onLogoutRequested,
});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text(AppStrings.unlockFeatureTitle),
),
body: MusicRiskFeatureView(
userId: userId,
cookies: cookies,
loginDateTime: loginDateTime,
loginId: loginId,
lastLoginDate: lastLoginDate,
onLogoutRequested: onLogoutRequested,
mode: MusicRiskFeatureMode.unlockMusic,
featureTitle: AppStrings.unlockFeatureTitle,
featureDesc: AppStrings.unlockFeatureDesc,
),
);
}
}
+3 -76
View File
@@ -10,14 +10,12 @@ class LoginResult {
final int errorId; final int errorId;
final int userId; final int userId;
final String token; final String token;
final String? cookies;
const LoginResult({ const LoginResult({
required this.success, required this.success,
required this.errorId, required this.errorId,
required this.userId, required this.userId,
required this.token, required this.token,
this.cookies,
}); });
} }
@@ -51,7 +49,7 @@ class ApiService {
return sha256.convert(bytes).toString(); return sha256.convert(bytes).toString();
} }
String extractQRCode(String qrCodeToken) { static String extractQRCode(String qrCodeToken) {
if (qrCodeToken.length > 64) { if (qrCodeToken.length > 64) {
return qrCodeToken.substring(qrCodeToken.length - 64); return qrCodeToken.substring(qrCodeToken.length - 64);
} }
@@ -94,85 +92,14 @@ class ApiService {
final userId = obj['userID'] as int? ?? -1; final userId = obj['userID'] as int? ?? -1;
final token = obj['token'] as String? ?? ''; final token = obj['token'] as String? ?? '';
// ── Extract ALL cookies from response headers ── // Session cookies (JSESSIONID) are captured from the title server's
// On web, Set-Cookie is a forbidden response header (browser hides it). // UserLoginApi response, not from the Aime server — see empurple.
// On native (dart:io), it appears as lowercase 'set-cookie'.
// The http package may join multiple Set-Cookie lines with ', '.
String? cookies;
// ignore: avoid_print
print('[login] ── Response headers (${response.headers.length} entries) ──');
final headerKeys = response.headers.keys.toList();
for (final key in headerKeys) {
final value = response.headers[key]!;
// ignore: avoid_print
print('[login] "$key": "$value"');
// Match any header name that smells like a cookie
final lower = key.toLowerCase();
if (lower.contains('cookie') || lower.contains('set-cookie')) {
cookies = value;
// ignore: avoid_print
print('[login] >>> Found cookie header: $key = $cookies');
}
}
// Also try direct lookup with various casings (just in case iteration misses)
if (cookies == null) {
for (final name in ['set-cookie', 'Set-Cookie', 'SET-COOKIE', 'cookie', 'Cookie', 'COOKIE']) {
final v = response.headers[name];
if (v != null && v.isNotEmpty) {
cookies = v;
// ignore: avoid_print
print('[login] >>> Direct lookup "$name" = $cookies');
break;
}
}
}
if (cookies == null) {
// ignore: avoid_print
print('[login] WARNING: No cookie/set-cookie header found in response.');
// Also check if response body has session info
if (obj.containsKey('jsessionId')) {
cookies = 'JSESSIONID=${obj['jsessionId']}';
// ignore: avoid_print
print('[login] >>> Found jsessionId in response body: $cookies');
} else if (obj.containsKey('sessionId')) {
cookies = 'JSESSIONID=${obj['sessionId']}';
// ignore: avoid_print
print('[login] >>> Found sessionId in response body: $cookies');
}
}
// Parse out individual cookie name=value pairs for clean forwarding
// (strip Path, HttpOnly, etc. attributes)
String? cleanCookies;
if (cookies != null) {
final parts = <String>[];
// Split on ', ' first (http package combines multiple Set-Cookie headers)
// Then split on '\n' (some server responses use newlines)
for (final chunk in cookies.split(RegExp(r', |\n'))) {
final trimmed = chunk.trim();
if (trimmed.isEmpty) continue;
// Extract "NAME=VALUE" before the first ';'
final semi = trimmed.indexOf(';');
final nv = semi > 0 ? trimmed.substring(0, semi) : trimmed;
if (nv.contains('=')) {
parts.add(nv);
}
}
cleanCookies = parts.join('; ');
// ignore: avoid_print
print('[login] Clean cookies to forward: $cleanCookies');
}
return LoginResult( return LoginResult(
success: errorId == 0, success: errorId == 0,
errorId: errorId, errorId: errorId,
userId: userId, userId: userId,
token: token, token: token,
cookies: cleanCookies,
); );
} }
} }
+1 -1
View File
@@ -1,6 +1,6 @@
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
const _channel = MethodChannel('com.ichino/qr_scanner'); const _channel = MethodChannel('dev.naominet.purarine/qr_scanner');
Future<String?> decodeQRFromBytes(Uint8List bytes) async { Future<String?> decodeQRFromBytes(Uint8List bytes) async {
try { try {
+29 -58
View File
@@ -93,46 +93,20 @@ class TitleApiService {
return _aesEncrypt(compressed); return _aesEncrypt(compressed);
} }
/// Port of empurple `HttpResult.cookieHeader()`: only the `JSESSIONID`
/// from the Set-Cookie response headers is used for subsequent requests.
void _captureCookiesFromResponse(http.Response response, String apiName) { void _captureCookiesFromResponse(http.Response response, String apiName) {
// ignore: avoid_print
print('[$apiName] ── Response headers (${response.headers.length} entries) ──');
final headerKeys = response.headers.keys.toList(); final headerKeys = response.headers.keys.toList();
String? rawCookies;
for (final key in headerKeys) { for (final key in headerKeys) {
if (!key.toLowerCase().contains('set-cookie')) continue;
final value = response.headers[key]!; final value = response.headers[key]!;
// ignore: avoid_print final match =
print('[$apiName] "$key": "$value"'); RegExp(r'JSESSIONID=([^;,]+)', caseSensitive: false).firstMatch(value);
final lower = key.toLowerCase(); if (match != null) {
if (lower.contains('set-cookie') || lower.contains('cookie')) { _cookies = 'JSESSIONID=${match.group(1)!.trim()}';
rawCookies = value;
// ignore: avoid_print // ignore: avoid_print
print('[$apiName] >>> Found cookie header: $key = $rawCookies'); print('[$apiName] >>> Captured session cookie: $_cookies');
} return;
}
if (rawCookies != null) {
// Parse JSESSIONID from the cookie value
// The jsessionid cookie is the session key from WaCa
final parts = <String>[];
for (final chunk in rawCookies.split(RegExp(r', |\n'))) {
final trimmed = chunk.trim();
if (trimmed.isEmpty) continue;
final semi = trimmed.indexOf(';');
final nv = semi > 0 ? trimmed.substring(0, semi) : trimmed;
if (nv.contains('=')) {
parts.add(nv);
final eq = nv.indexOf('=');
final name = nv.substring(0, eq);
final value = nv.substring(eq + 1);
// ignore: avoid_print
print('[$apiName] >>> Cookie pair: $name = $value');
}
}
final cleanCookies = parts.join('; ');
if (cleanCookies.isNotEmpty) {
_cookies = cleanCookies;
// ignore: avoid_print
print('[$apiName] >>> Stored cookies for subsequent requests: $_cookies');
} }
} }
} }
@@ -184,14 +158,15 @@ class TitleApiService {
// ignore: avoid_print // ignore: avoid_print
print(packetStr); print(packetStr);
final uaSuffix = userId != 0 ? '$userId' : _config.clientId;
final headers = <String, String>{ final headers = <String, String>{
'User-Agent': '$hash#$userId', 'User-Agent': '$hash#$uaSuffix',
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Mai-Encoding': _config.apiVersion, 'Mai-Encoding': _config.apiVersion,
'Accept-Encoding': '', 'Accept-Encoding': '',
'Charset': 'UTF-8', 'Charset': 'UTF-8',
'Content-Encoding': 'deflate', 'Content-Encoding': 'deflate',
'Host': 'maimai-gm.wahlap.com:42081', 'number': '0',
}; };
final cookies = _cookies; final cookies = _cookies;
if (cookies != null) { if (cookies != null) {
@@ -307,7 +282,7 @@ class TitleApiService {
'Accept-Encoding': '', 'Accept-Encoding': '',
'Charset': 'UTF-8', 'Charset': 'UTF-8',
'Content-Encoding': 'deflate', 'Content-Encoding': 'deflate',
'Host': 'maimai.wahlap.com:42081', 'number': '0',
}; };
final previewCookies = _cookies; final previewCookies = _cookies;
if (previewCookies != null) { if (previewCookies != null) {
@@ -434,6 +409,17 @@ class TitleApiService {
// ---- UserLogout ---- // ---- UserLogout ----
/// Port of empurple/eaquira behavior: the server may omit `returnCode`
/// for fire-and-forget APIs. Only a present-but-not-1 code is an error.
void _checkReturnCode(Map<String, dynamic> json, String apiName) {
final returnCode = json['returnCode'];
if (returnCode == null) return;
final code = (returnCode as num).toInt();
if (code != 1) {
throw TitleApiException('$apiName returnCode=$code');
}
}
Future<void> userLogout({ Future<void> userLogout({
required int userId, required int userId,
required int loginDateTime, required int loginDateTime,
@@ -451,16 +437,10 @@ class TitleApiService {
}; };
final json = await _callApi(apiName, packet, userId); final json = await _callApi(apiName, packet, userId);
final returnCode = json['returnCode'] as int? ?? -1; _checkReturnCode(json, apiName);
if (returnCode != 1) {
throw TitleApiException('UserLogoutApi returnCode=$returnCode');
}
} }
// ---- UpsertUserChargeLog (使用功能票) ---- // ---- UpsertUserChargeLog (使用功能票) ----
// Reference: KanadeBot SendTicketCommand / PayloadBuilder.generateTicketRequest
static const _ticketPriceMap = {1: 1, 2: 6, 3: 2, 4: 2, 5: 2, 6: 0};
Future<void> upsertUserChargeLog({ Future<void> upsertUserChargeLog({
required int userId, required int userId,
@@ -482,7 +462,7 @@ class TitleApiService {
'userId': userId, 'userId': userId,
'userChargelog': { 'userChargelog': {
'chargeId': ticketId, 'chargeId': ticketId,
'price': _ticketPriceMap[ticketId] ?? 0, 'price': 0,
'purchaseDate': purchaseDateStr, 'purchaseDate': purchaseDateStr,
'playCount': playCount, 'playCount': playCount,
'playerRating': playerRating, 'playerRating': playerRating,
@@ -500,10 +480,7 @@ class TitleApiService {
}; };
final json = await _callApi(apiName, packet, userId); final json = await _callApi(apiName, packet, userId);
final returnCode = json['returnCode'] as int? ?? -1; _checkReturnCode(json, apiName);
if (returnCode != 1) {
throw TitleApiException('UpsertUserChargelogApi returnCode=$returnCode');
}
} }
String _formatDateTime(DateTime dt) { String _formatDateTime(DateTime dt) {
@@ -523,10 +500,7 @@ class TitleApiService {
'userId': userId, 'userId': userId,
'userPlaylog': userPlaylog, 'userPlaylog': userPlaylog,
}, userId); }, userId);
final returnCode = json['returnCode'] as int? ?? -1; _checkReturnCode(json, apiName);
if (returnCode != 1) {
throw TitleApiException('UploadUserPlaylogApi returnCode=$returnCode');
}
} }
/// Fetch all user data needed to build an UpsertUserAll payload. /// Fetch all user data needed to build an UpsertUserAll payload.
@@ -560,10 +534,7 @@ class TitleApiService {
) async { ) async {
const apiName = 'UpsertUserAllApi'; const apiName = 'UpsertUserAllApi';
final json = await _callApi(apiName, packet, userId); final json = await _callApi(apiName, packet, userId);
final returnCode = json['returnCode'] as int? ?? -1; _checkReturnCode(json, apiName);
if (returnCode != 1) {
throw TitleApiException('UpsertUserAllApi returnCode=$returnCode');
}
} }
/// Fetch user character list. /// Fetch user character list.
+381
View File
@@ -0,0 +1,381 @@
import '../config/title_server_config.dart';
import 'title_api_service.dart';
/// Dart port of `eaquira/src/sdgb/payload.py::UserAll_payload` and
/// `eaquira/action/UnlockMusic.py::music_user_all_patcher`.
///
/// Builds the UpsertUserAllApi request packet used by the
/// UpsertMusic (上传成绩) / UnlockMusic (解锁歌曲) workflows.
class UserAllPayloadBuilder {
final TitleServerConfig config;
const UserAllPayloadBuilder(this.config);
static DateTime _shanghaiNow() =>
DateTime.now().toUtc().add(const Duration(hours: 8));
static String _two(int v) => v.toString().padLeft(2, '0');
static String _formatPlayDate(DateTime dt) =>
'${dt.year}-${_two(dt.month)}-${_two(dt.day)}';
static String _formatPlayDateTime(DateTime dt) =>
'${dt.year}-${_two(dt.month)}-${_two(dt.day)} '
'${_two(dt.hour)}:${_two(dt.minute)}:${_two(dt.second)}.0';
static int _toInt(dynamic v) {
if (v is int) return v;
if (v is num) return v.toInt();
return 0;
}
/// "1.56.00" -> 1056000 (Lionheart convertVersionNumber).
static int _convertVersionNumber(String version) {
final parts = version.split('.');
if (parts.length != 3) return 1053000;
final major = int.tryParse(parts[0]);
final minor = int.tryParse(parts[1]);
final patch = int.tryParse(parts[2]);
if (major == null || minor == null || patch == null) return 1053000;
return major * 1000000 + minor * 1000 + patch;
}
/// Build the UpsertUserAllApi packet.
///
/// [generalUserInfo] is the result of `TitleApiService.fetchUserAllData`,
/// keyed by the raw API names.
Map<String, dynamic> build({
required int userId,
required int loginId,
required int loginDateTime,
required Map<String, dynamic> musicData,
required Map<String, Map<String, dynamic>> generalUserInfo,
}) {
final userDataResp = generalUserInfo['GetUserDataApi'] ?? const {};
final userExtendResp = generalUserInfo['GetUserExtendApi'] ?? const {};
final userOptionResp = generalUserInfo['GetUserOptionApi'] ?? const {};
final userRatingResp = generalUserInfo['GetUserRatingApi'] ?? const {};
final userChargeResp = generalUserInfo['GetUserChargeApi'] ?? const {};
final userActivityResp = generalUserInfo['GetUserActivityApi'] ?? const {};
final userMissionResp = generalUserInfo['GetUserMissionDataApi'] ?? const {};
final userData =
userDataResp['userData'] as Map<String, dynamic>? ?? const {};
final playerRating = _toInt(userData['playerRating']);
final charaSlot = (userData['charaSlot'] as List<dynamic>? ?? const [])
.map((e) => _toInt(e))
.toList();
final paddedCharaSlot = List<int>.generate(
5,
(i) => i < charaSlot.length ? charaSlot[i] : 0,
);
final now = _shanghaiNow();
final playDate = _formatPlayDate(now);
final playDateTime = _formatPlayDateTime(now);
final loginDate =
_formatPlayDateTime(DateTime.fromMillisecondsSinceEpoch(loginDateTime * 1000).toUtc().add(const Duration(hours: 8)));
// Lionheart: GetUserDataApi 返回的部分字段不能出现在 UpsertUserAll 的
// userData 中,否则服务器会静默丢弃整包处理。
final newUserData = Map<String, dynamic>.from(userData)
..remove('friendCode')
..remove('nameplateId')
..remove('trophyId')
..remove('cmLastEmoneyBrand')
..remove('cmLastEmoneyCredit');
newUserData
..['accessCode'] = ''
..['isNetMember'] = 1
..['playCount'] = _toInt(userData['playCount']) + 1
..['currentPlayCount'] = _toInt(userData['currentPlayCount']) + 1
..['lastGameId'] = 'SDGB'
..['lastLoginDate'] = loginDate
..['lastPlayDate'] = playDateTime
..['lastPlayCredit'] = 1
..['lastPlayMode'] = 0
..['lastPlaceId'] = config.placeId
..['lastPlaceName'] = config.placeName
..['lastAllNetId'] = 0
..['lastRegionId'] = config.regionId
..['lastRegionName'] = config.regionName
..['lastClientId'] = config.clientId
..['lastCountryCode'] = 'CHN'
..['banState'] = userDataResp['banState']
..['dateTime'] = loginDateTime;
// Lionheart: userOption 需去掉 tempoVolume。
final userOption =
userOptionResp['userOption'] as Map<String, dynamic>? ?? const {};
final newUserOption = Map<String, dynamic>.from(userOption)
..remove('tempoVolume');
// Lionheart: userRating.udemae 的大写重复字段需去掉。
final userRating =
userRatingResp['userRating'] as Map<String, dynamic>? ?? const {};
final udemae = userRating['udemae'] as Map<String, dynamic>?;
if (udemae != null) {
for (final key in [
'MaxLoseNum',
'NpcLoseNum',
'NpcMaxLoseNum',
'NpcMaxWinNum',
'NpcTotalLoseNum',
'NpcTotalWinNum',
'NpcWinNum',
]) {
udemae.remove(key);
}
}
// Lionheart: userChargeList 只保留 4 个字段 (无 extNum1)。
final userChargeList = [
for (final c in (userChargeResp['userChargeList'] as List<dynamic>? ??
const []))
if (c is Map<String, dynamic>)
{
'chargeId': c['chargeId'],
'stock': c['stock'],
'purchaseDate': c['purchaseDate'],
'validDate': c['validDate'],
},
];
final missionList =
userMissionResp['userMissionDataList'] as List<dynamic>? ?? const [];
final missions = <Map<String, dynamic>>[];
for (final raw in missionList.take(6)) {
final m = raw as Map<String, dynamic>;
missions.add({
'type': m['type'],
'difficulty': m['difficulty'],
'targetGenreId': m['targetGenreId'],
'targetGenreTableId': m['targetGenreTableId'],
'conditionGenreId': m['conditionGenreId'],
'conditionGenreTableId': m['conditionGenreTableId'],
'clearFlag': m['clearFlag'],
});
}
final weekly =
userMissionResp['userWeeklyData'] as Map<String, dynamic>? ?? const {};
final achievement = _toInt(musicData['achievement']);
final gradeRating = _toInt(userData['gradeRating']);
final playlog = <String, dynamic>{
'userId': 0,
'orderId': 0,
'playlogId': loginId,
'version': _convertVersionNumber(
userData['lastRomVersion'] as String? ?? '',
),
'placeId': config.placeId,
'placeName': config.placeName,
'loginDate': loginDateTime,
'playDate': playDate,
'userPlayDate': playDateTime,
'type': 0,
'musicId': musicData['musicId'],
'level': musicData['level'],
'trackNo': 1,
'vsMode': 0,
'vsUserName': '',
'vsStatus': 0,
'vsUserRating': 0,
'vsUserAchievement': 0,
'vsUserGradeRank': 0,
'vsRank': 0,
'playerNum': 1,
'playedUserId1': 0,
'playedUserName1': '',
'playedMusicLevel1': 0,
'playedUserId2': 0,
'playedUserName2': '',
'playedMusicLevel2': 0,
'playedUserId3': 0,
'playedUserName3': '',
'playedMusicLevel3': 0,
'achievement': achievement,
'deluxscore': musicData['deluxscoreMax'],
'scoreRank': musicData['scoreRank'],
'maxCombo': 0,
'totalCombo': 1,
'maxSync': 1,
'totalSync': 0,
'tapCriticalPerfect': 0,
'tapPerfect': 0,
'tapGreat': 0,
'tapGood': 0,
'tapMiss': 1,
'holdCriticalPerfect': 0,
'holdPerfect': 0,
'holdGreat': 0,
'holdGood': 0,
'holdMiss': 0,
'slideCriticalPerfect': 0,
'slidePerfect': 0,
'slideGreat': 0,
'slideGood': 0,
'slideMiss': 0,
'touchCriticalPerfect': 0,
'touchPerfect': 0,
'touchGreat': 0,
'touchGood': 0,
'touchMiss': 0,
'breakCriticalPerfect': 0,
'breakPerfect': 0,
'breakGreat': 0,
'breakGood': 0,
'breakMiss': 0,
'isTap': true,
'isHold': false,
'isSlide': false,
'isTouch': false,
'isBreak': false,
'isCriticalDisp': false,
'isFastLateDisp': true,
'fastCount': 0,
'lateCount': 0,
'isAchieveNewRecord': false,
'isDeluxscoreNewRecord': false,
'comboStatus': musicData['comboStatus'],
'syncStatus': musicData['syncStatus'],
'isClear': achievement >= 500000,
'beforeRating': playerRating,
'afterRating': playerRating,
'beforeGrade': gradeRating,
'afterGrade': gradeRating,
'afterGradeRank': _toInt(userData['gradeRank']),
'beforeDeluxRating': playerRating,
'afterDeluxRating': playerRating,
'isPlayTutorial': false,
'isEventMode': false,
'isFreedomMode': false,
'playMode': 0,
'isNewFree': false,
'trialPlayAchievement': -1,
'extNum1': 0,
'extNum2': 0,
'extNum4': 0,
'extBool1': false,
'extBool2': false,
};
for (var i = 0; i < 5; i++) {
playlog['characterId${i + 1}'] = paddedCharaSlot[i];
playlog['characterLevel${i + 1}'] = 1;
playlog['characterAwakening${i + 1}'] = 0;
}
return {
'userId': userId,
'playlogId': loginId,
'isEventMode': false,
'isFreePlay': false,
'loginDateTime': loginDateTime,
'userPlaylogList': [playlog],
'upsertUserAll': {
'userData': [newUserData],
'userExtend': [userExtendResp['userExtend']],
'userOption': [newUserOption],
'userCharacterList': [],
'userGhost': [],
'userMapList': [],
'userLoginBonusList': [],
'userRatingList': [userRating],
'userItemList': [],
'userMusicDetailList': [musicData],
'userCourseList': [],
'userFriendSeasonRankingList': [],
'userChargeList': userChargeList,
'userFavoriteList': [],
'userActivityList': [userActivityResp['userActivity']],
'userMissionDataList': missions,
'userWeeklyData': {
'lastLoginWeek': weekly['lastLoginWeek'],
'beforeLoginWeek': weekly['beforeLoginWeek'],
'friendBonusFlag': weekly['friendBonusFlag'],
},
'userGamePlaylogList': [
{
'playlogId': loginId,
'version': userData['lastRomVersion'],
'playDate': playDateTime,
'playMode': 0,
'useTicketId': -1,
'playCredit': 1,
'playTrack': 1,
'clientId': config.clientId,
'isPlayTutorial': false,
'isEventMode': false,
'isNewFree': false,
'playCount': 0,
'playSpecial': TitleApiService.calcRandom(),
'playOtherUserId': 0,
},
],
'user2pPlaylog': {
'userId1': 0,
'userId2': 0,
'userName1': '',
'userName2': '',
'regionId': 0,
'placeId': 0,
'user2pPlaylogDetailList': [],
},
'userIntimateList': [],
'userShopItemStockList': [],
'userGetPointList': [],
'userTradeItemList': [],
'userFavoritemusicList': [],
'userKaleidxScopeList': [],
'isNewCharacterList': '',
'isNewMapList': '',
'isNewLoginBonusList': '',
'isNewItemList': '',
'isNewMusicDetailList': '0',
'isNewCourseList': '',
'isNewFavoriteList': '',
'isNewFriendSeasonRankingList': '',
'isNewUserIntimateList': '',
'isNewFavoritemusicList': '',
'isNewKaleidxScopeList': '',
},
};
}
/// Generic item patch: replace `userItemList` / `isNewItemList` in the packet.
/// Used by UnlockMusic (itemKind 5/6/7) and 收藏品获取 (itemKind 1/2/3/10/11/12).
void applyItemListPatch(
Map<String, dynamic> packet, {
required List<Map<String, dynamic>> items,
}) {
final upsert = packet['upsertUserAll'] as Map<String, dynamic>;
upsert['userItemList'] = items;
upsert['isNewItemList'] = List.filled(items.length, '1').join();
}
/// Port of `music_user_all_patcher`: append unlock items
/// (itemKind 5=music, 6=master, 7=remaster) to the packet.
void applyMusicUnlockPatch(
Map<String, dynamic> packet, {
required Map<String, dynamic> musicData,
required int musicId,
bool unlockMusic = false,
bool unlockMaster = false,
bool unlockRemaster = false,
}) {
applyItemListPatch(packet, items: [
if (unlockMusic)
{'itemKind': 5, 'itemId': musicId, 'stock': 1, 'isValid': true},
if (unlockMaster)
{'itemKind': 6, 'itemId': musicId, 'stock': 1, 'isValid': true},
if (unlockRemaster)
{'itemKind': 7, 'itemId': musicId, 'stock': 1, 'isValid': true},
]);
final upsert = packet['upsertUserAll'] as Map<String, dynamic>;
upsert['userMusicDetailList'] = [musicData];
upsert['isNewMusicDetailList'] = '1';
}
}
+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 "ichino") set(BINARY_NAME "purarine")
# 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 "com.example.ichino") set(APPLICATION_ID "dev.naominet.purarine")
# 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.
+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, "ichino"); gtk_header_bar_set_title(header_bar, "Purarine");
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, "ichino"); gtk_window_set_title(window, "Purarine");
} }
gtk_window_set_default_size(window, 1280, 720); gtk_window_set_default_size(window, 1280, 720);
+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 /* ichino.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "ichino.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 33CC10ED2044A3C60003C045 /* Purarine.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Purarine.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 /* ichino.app */, 33CC10ED2044A3C60003C045 /* Purarine.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 /* ichino.app */; productReference = 33CC10ED2044A3C60003C045 /* Purarine.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 = com.example.ichino.RunnerTests; PRODUCT_BUNDLE_IDENTIFIER = dev.naominet.purarine.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/ichino.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/ichino"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Purarine.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Purarine";
}; };
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 = com.example.ichino.RunnerTests; PRODUCT_BUNDLE_IDENTIFIER = dev.naominet.purarine.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/ichino.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/ichino"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Purarine.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Purarine";
}; };
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 = com.example.ichino.RunnerTests; PRODUCT_BUNDLE_IDENTIFIER = dev.naominet.purarine.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/ichino.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/ichino"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Purarine.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Purarine";
}; };
name = Profile; name = Profile;
}; };
@@ -15,7 +15,7 @@
<BuildableReference <BuildableReference
BuildableIdentifier = "primary" BuildableIdentifier = "primary"
BlueprintIdentifier = "33CC10EC2044A3C60003C045" BlueprintIdentifier = "33CC10EC2044A3C60003C045"
BuildableName = "ichino.app" BuildableName = "Purarine.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 = "ichino.app" BuildableName = "Purarine.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 = "ichino.app" BuildableName = "Purarine.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 = "ichino.app" BuildableName = "Purarine.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 = "ichino.app" BuildableName = "Purarine.app"
BlueprintName = "Runner" BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj"> ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference> </BuildableReference>
+3 -3
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 = ichino PRODUCT_NAME = Purarine
// The application's bundle identifier // The application's bundle identifier
PRODUCT_BUNDLE_IDENTIFIER = com.example.ichino PRODUCT_BUNDLE_IDENTIFIER = dev.naominet.purarine
// The copyright displayed in application information // The copyright displayed in application information
PRODUCT_COPYRIGHT = Copyright © 2026 com.example. All rights reserved. PRODUCT_COPYRIGHT = Copyright © 2026 dev.naominet. All rights reserved.
+8 -8
View File
@@ -308,10 +308,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: matcher name: matcher
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.12.19" version: "0.12.20"
material_color_utilities: material_color_utilities:
dependency: transitive dependency: transitive
description: description:
@@ -324,10 +324,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: meta name: meta
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.18.0" version: "1.19.0"
mime: mime:
dependency: transitive dependency: transitive
description: description:
@@ -505,10 +505,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.11" version: "0.7.12"
typed_data: typed_data:
dependency: transitive dependency: transitive
description: description:
@@ -521,10 +521,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: vector_math name: vector_math
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.0" version: "2.4.2"
vm_service: vm_service:
dependency: transitive dependency: transitive
description: description:
+1 -1
View File
@@ -1,4 +1,4 @@
name: ichino name: purarine
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.
+4 -20
View File
@@ -1,30 +1,14 @@
// This is a basic Flutter widget test.
//
// To perform an interaction with a widget in your test, use the WidgetTester
// utility in the flutter_test package. For example, you can send tap and scroll
// gestures. You can also use WidgetTester to find child widgets in the widget
// tree, read text, and verify that the values of widget properties are correct.
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:ichino/main.dart'; import 'package:purarine/main.dart';
void main() { void main() {
testWidgets('Counter increments smoke test', (WidgetTester tester) async { testWidgets('App renders login page with app title', (WidgetTester tester) async {
// Build our app and trigger a frame.
await tester.pumpWidget(const MyApp()); await tester.pumpWidget(const MyApp());
// Verify that our counter starts at 0.
expect(find.text('0'), findsOneWidget);
expect(find.text('1'), findsNothing);
// Tap the '+' icon and trigger a frame.
await tester.tap(find.byIcon(Icons.add));
await tester.pump(); await tester.pump();
// Verify that our counter has incremented. expect(find.text('Purarine'), findsWidgets);
expect(find.text('0'), findsNothing); expect(find.byType(MaterialApp), findsOneWidget);
expect(find.text('1'), 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="Ichino - maimaidx login client"> <meta name="description" content="Purarine - 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="ichino"> <meta name="apple-mobile-web-app-title" content="Purarine">
<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>Ichino</title> <title>Purarine</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": "ichino", "name": "Purarine",
"short_name": "ichino", "short_name": "Purarine",
"start_url": ".", "start_url": ".",
"display": "standalone", "display": "standalone",
"background_color": "#0175C2", "background_color": "#0175C2",
View File
Whitespace-only changes.
+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(ichino LANGUAGES CXX) project(purarine 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 "ichino") set(BINARY_NAME "purarine")
# 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.
+6 -6
View File
@@ -89,13 +89,13 @@ BEGIN
BEGIN BEGIN
BLOCK "040904e4" BLOCK "040904e4"
BEGIN BEGIN
VALUE "CompanyName", "com.example" "\0" VALUE "CompanyName", "dev.naominet" "\0"
VALUE "FileDescription", "ichino" "\0" VALUE "FileDescription", "Purarine" "\0"
VALUE "FileVersion", VERSION_AS_STRING "\0" VALUE "FileVersion", VERSION_AS_STRING "\0"
VALUE "InternalName", "ichino" "\0" VALUE "InternalName", "purarine" "\0"
VALUE "LegalCopyright", "Copyright (C) 2026 com.example. All rights reserved." "\0" VALUE "LegalCopyright", "Copyright (C) 2026 dev.naominet. All rights reserved." "\0"
VALUE "OriginalFilename", "ichino.exe" "\0" VALUE "OriginalFilename", "purarine.exe" "\0"
VALUE "ProductName", "ichino" "\0" VALUE "ProductName", "Purarine" "\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"ichino", origin, size)) { if (! window.Create(L"Purarine", origin, size)) {
return EXIT_FAILURE; return EXIT_FAILURE;
} }
window.SetQuitOnClose(true); window.SetQuitOnClose(true);