sync
This commit is contained in:
1 parent
0b3282da5c
commit
24ccd628d1
43 files changed
+2099
-1490
No files matched your search
Vendored
+3
-3
@@ -5,14 +5,14 @@
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "ichino",
|
||||
"name": "Purarine",
|
||||
"request": "launch",
|
||||
"type": "dart",
|
||||
"program": "lib/main.dart",
|
||||
"deviceId": "windows"
|
||||
},
|
||||
{
|
||||
"name": "ichino (profile mode)",
|
||||
"name": "Purarine (profile mode)",
|
||||
"request": "launch",
|
||||
"type": "dart",
|
||||
"program": "lib/main.dart",
|
||||
@@ -20,7 +20,7 @@
|
||||
"flutterMode": "profile"
|
||||
},
|
||||
{
|
||||
"name": "ichino (release mode)",
|
||||
"name": "Purarine (release mode)",
|
||||
"request": "launch",
|
||||
"type": "dart",
|
||||
"program": "lib/main.dart",
|
||||
|
||||
Vendored
+2
-2
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"dart.flutterHotReloadOnSave": "all",
|
||||
"dart.flutterSdkPath": "D:\\fluttersdk",
|
||||
"dart.sdkPath": "D:\\fluttersdk\\bin\\cache\\dart-sdk"
|
||||
"dart.flutterSdkPath": "D:\\flutterdev\\flutter",
|
||||
"dart.sdkPath": "D:\\flutterdev\\flutter\\bin\\cache\\dart-sdk"
|
||||
}
|
||||
@@ -7,6 +7,15 @@
|
||||
|
||||
# The following line activates a set of recommended lints for Flutter apps,
|
||||
# packages, and plugins designed to encourage good coding practices.
|
||||
analyzer:
|
||||
exclude:
|
||||
- build/**
|
||||
- android/**
|
||||
- ios/**
|
||||
- web/**
|
||||
- windows/**
|
||||
- macos/**
|
||||
- linux/**
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
linter:
|
||||
|
||||
@@ -5,7 +5,7 @@ plugins {
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.example.ichino"
|
||||
namespace = "dev.naominet.purarine"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
@@ -15,7 +15,7 @@ android {
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.example.ichino"
|
||||
applicationId = "dev.naominet.purarine"
|
||||
minSdk = flutter.minSdkVersion
|
||||
targetSdk = flutter.targetSdkVersion
|
||||
versionCode = flutter.versionCode
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<application
|
||||
android:label="ichino"
|
||||
android:label="Purarine"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:usesCleartextTraffic="true">
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
package com.example.ichino
|
||||
package dev.naominet.purarine
|
||||
|
||||
import android.graphics.BitmapFactory
|
||||
import com.google.mlkit.vision.barcode.BarcodeScanning
|
||||
@@ -9,7 +9,7 @@ import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
|
||||
class MainActivity : FlutterActivity() {
|
||||
private val CHANNEL = "com.ichino/qr_scanner"
|
||||
private val CHANNEL = "dev.naominet.purarine/qr_scanner"
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
@@ -475,7 +475,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = dev.ichino;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = dev.naominet.purarine;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
@@ -493,7 +493,7 @@
|
||||
DEVELOPMENT_TEAM = 3ACWHAQMGP;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.ichino.RunnerTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = dev.naominet.purarine.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
@@ -512,7 +512,7 @@
|
||||
DEVELOPMENT_TEAM = 3ACWHAQMGP;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.ichino.RunnerTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = dev.naominet.purarine.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||
@@ -529,7 +529,7 @@
|
||||
DEVELOPMENT_TEAM = 3ACWHAQMGP;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.ichino.RunnerTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = dev.naominet.purarine.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||
@@ -663,7 +663,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = dev.ichino;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = dev.naominet.purarine;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
@@ -688,7 +688,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = dev.ichino;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = dev.naominet.purarine;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Ichino</string>
|
||||
<string>Purarine</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
@@ -15,7 +15,7 @@
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>ichino</string>
|
||||
<string>purarine</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
|
||||
@@ -14,7 +14,7 @@ class SceneDelegate: FlutterSceneDelegate {
|
||||
guard let controller = window?.rootViewController as? FlutterViewController else { return }
|
||||
|
||||
let channel = FlutterMethodChannel(
|
||||
name: "com.ichino/qr_scanner",
|
||||
name: "dev.naominet.purarine/qr_scanner",
|
||||
binaryMessenger: controller.binaryMessenger
|
||||
)
|
||||
|
||||
|
||||
+69
-55
@@ -4,12 +4,12 @@ class AppStrings {
|
||||
// Tab
|
||||
static const tabHome = '主页';
|
||||
static const tabTickets = '票据';
|
||||
static const tabTransfer = '传分';
|
||||
static const tabRisk = '风险';
|
||||
static const tabSettings = '设置';
|
||||
static const tabAbout = '关于';
|
||||
|
||||
// Login
|
||||
static const appTitle = 'Project Ichino';
|
||||
static const appTitle = 'Purarine';
|
||||
static const loginSubtitle = '扫描或输入二维码进行登录';
|
||||
static const qrCodeToken = 'QR Code 令牌';
|
||||
static const qrHint = '粘贴二维码解析内容...';
|
||||
@@ -110,6 +110,7 @@ class AppStrings {
|
||||
static const refreshTickets = '刷新数据';
|
||||
static const loading = '加载中...';
|
||||
static const ticketNotSelected = '请先在功能票列表中选中一张票。';
|
||||
static const ticketStockNotEmpty = '该功能票已有库存,出于安全考虑不会继续发票。';
|
||||
static const selectedTicket = '已选票';
|
||||
static const ticketUsed = '功能票使用完成';
|
||||
static const ticketCooldownSeconds = 60;
|
||||
@@ -117,11 +118,9 @@ class AppStrings {
|
||||
'冷却中,剩余 $remaining 秒';
|
||||
static String ticketCooldownNotice(int remaining) =>
|
||||
'登录后需冷却 $ticketCooldownSeconds 秒,剩余 $remaining 秒后可使用功能票。';
|
||||
static String packetCooldownNotice(int remaining) =>
|
||||
'登录后需冷却 $ticketCooldownSeconds 秒,剩余 $remaining 秒后可上传成绩。';
|
||||
|
||||
// Settings
|
||||
static const titleServerSettings = 'Title Server 设置';
|
||||
static const titleServerSettings = '参数设置';
|
||||
static const titleServerSection = 'Title Server';
|
||||
static const authServerSettings = 'Auth Server';
|
||||
static const machineSettings = '机器信息';
|
||||
@@ -163,62 +162,77 @@ class AppStrings {
|
||||
static const importFailed = '导入失败:剪贴板内容无效';
|
||||
static const noConfigToExport = '没有可导出的配置';
|
||||
|
||||
// Transfer Score (传分 / UpsertUserAll 上传成绩)
|
||||
static const transferTitle = '传送分数';
|
||||
static const transferDesc =
|
||||
'新版本机台容易崩溃导致成绩传不上去。在此填入一首歌的成绩,程序会作为一次有效游玩记录上传回服务器。';
|
||||
static const transferFetchData = '拉取数据';
|
||||
static const transferFetching = '拉取中...';
|
||||
static const transferRefetch = '重新拉取';
|
||||
static const transferNotLoggedIn = '尚未登录游戏服务器,无法上传成绩。';
|
||||
static const transferDataReady = '数据已就绪,填写下方成绩后点击上传。';
|
||||
// UnlockMusic (解锁歌曲 / UpsertUserAll 上传解锁道具)
|
||||
static const unlockFeatureTitle = '歌曲解锁';
|
||||
static const unlockFeatureDesc = '通过上传 itemKind 5/6/7 道具解锁歌曲与谱面。';
|
||||
static const unlockMusicIdLabel = '解锁歌曲 ID';
|
||||
static const unlockMusicIdHint = '例如 834';
|
||||
static const unlockMusicOption = '解锁歌曲 (itemKind 5)';
|
||||
static const unlockMasterOption = '解锁 Master (itemKind 6)';
|
||||
static const unlockRemasterOption = '解锁 Re:Master (itemKind 7)';
|
||||
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
|
||||
static const scoreFormTitle = '成绩信息';
|
||||
static const scoreMusicId = '歌曲 ID (musicId)';
|
||||
static const scoreLevel = '难度';
|
||||
static const scoreAchievement = '达成率 (achievement)';
|
||||
static const scoreAchievementHint = '例如 1010000 = 101.0000%';
|
||||
static const scoreComboStatus = 'Combo 状态';
|
||||
static const scoreSyncStatus = 'Sync 状态';
|
||||
static const scoreDeluxscore = 'DX 分 (deluxscoreMax)';
|
||||
static const scorePlayCount = '游玩次数 (playCount)';
|
||||
static const scoreRankLabel = '评级 (自动)';
|
||||
static const scoreInvalidInput = '请填写有效的歌曲 ID 与达成率。';
|
||||
// Collectibles (收藏品获取 / UpsertUserAll 上传收藏品道具)
|
||||
// itemKind: 1=姓名框 2=称号 3=头像 10=搭档 11=背景板 12=功能票
|
||||
static const collectiblesFeatureTitle = '收藏品获取';
|
||||
static const collectiblesFeatureDesc =
|
||||
'通过上传 itemKind 道具获取收藏品(姓名框/称号/头像/搭档/背景板/功能票)。';
|
||||
static const collectiblesItemKindLabel = '收藏品类型 (itemKind)';
|
||||
static const collectiblesItemIdLabel = '收藏品 ID';
|
||||
static const collectiblesItemIdHint = '例如 250103';
|
||||
static const collectiblesNeedItemId = '请填写要获取的收藏品 ID。';
|
||||
static const collectiblesNotLoggedIn = '尚未登录游戏服务器,无法执行获取操作。';
|
||||
static String collectiblesCooldownNotice(int remaining) =>
|
||||
'登录后需冷却 $ticketCooldownSeconds 秒,剩余 $remaining 秒后可执行获取操作。';
|
||||
static const collectiblesRun = '执行获取';
|
||||
static const collectiblesStepUpload = '上传获取数据';
|
||||
static const collectiblesSuccess = '收藏品获取数据上传完成';
|
||||
|
||||
static const List<String> levelLabels = [
|
||||
'Basic (绿)',
|
||||
'Advanced (黄)',
|
||||
'Expert (红)',
|
||||
'Master (紫)',
|
||||
'Re:Master (白)',
|
||||
];
|
||||
static const List<int> collectiblesItemKinds = [1, 2, 3, 10, 11, 12];
|
||||
|
||||
static const List<String> comboStatusLabels = [
|
||||
'无',
|
||||
'FC (Full Combo)',
|
||||
'FC+ (Full Combo+)',
|
||||
'AP (All Perfect)',
|
||||
'AP+ (All Perfect+)',
|
||||
];
|
||||
static String collectiblesItemKindName(int kind) {
|
||||
switch (kind) {
|
||||
case 1:
|
||||
return '姓名框';
|
||||
case 2:
|
||||
return '称号';
|
||||
case 3:
|
||||
return '头像';
|
||||
case 10:
|
||||
return '搭档';
|
||||
case 11:
|
||||
return '背景板';
|
||||
case 12:
|
||||
return '功能票';
|
||||
default:
|
||||
return '$kind';
|
||||
}
|
||||
}
|
||||
|
||||
static const List<String> syncStatusLabels = [
|
||||
'无',
|
||||
'FS (Full Sync)',
|
||||
'FS+ (Full Sync+)',
|
||||
'FDX (Full Sync DX)',
|
||||
'FDX+ (Full Sync DX+)',
|
||||
];
|
||||
|
||||
static const transferSend = '上传成绩';
|
||||
static const transferSending = '上传中...';
|
||||
static const transferSuccess = '成绩上传完成';
|
||||
static const transferFailed = '成绩上传失败';
|
||||
static const transferSlotLabel = '槽位';
|
||||
static const transferNoData = '⚠️ 未拉取到用户数据,请先点「拉取数据」按钮获取。';
|
||||
// HighRiskFeature hub (中转页)
|
||||
static const musicRiskHubTitle = '高危功能';
|
||||
static const musicRiskHubDesc = '以下功能会向服务器发送伪造数据,属于高风险操作,请谨慎使用。';
|
||||
static const musicRiskHubNotLoggedIn = '尚未登录游戏服务器,无法执行高危操作。';
|
||||
static String musicRiskHubCooldownNotice(int remaining) =>
|
||||
'登录后需冷却 $ticketCooldownSeconds 秒,剩余 $remaining 秒后可执行高危操作。';
|
||||
|
||||
// About
|
||||
static const aboutTitle = 'Project Ichino';
|
||||
static const aboutTitle = 'Purarine';
|
||||
static const aboutDesc = '基于 QR Code 的 maimai DX 街机网络登录客户端。';
|
||||
static const credits = '致谢';
|
||||
static const creditBuiltWith = '构建框架';
|
||||
|
||||
+4
-7
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'config/responsive.dart';
|
||||
import 'config/strings.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/qr_service.dart';
|
||||
import 'pages/home_page.dart';
|
||||
@@ -49,7 +49,6 @@ class LoginPage extends StatefulWidget {
|
||||
|
||||
class _LoginPageState extends State<LoginPage> {
|
||||
final _qrController = TextEditingController();
|
||||
final _apiService = ApiService();
|
||||
String _qrCode = '';
|
||||
bool _loading = false;
|
||||
bool _forcePreviewApi = false;
|
||||
@@ -62,7 +61,7 @@ class _LoginPageState extends State<LoginPage> {
|
||||
|
||||
void _onQRContentChanged(String value) {
|
||||
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) {
|
||||
_qrController.text = result;
|
||||
setState(() {
|
||||
_qrCode = _apiService.extractQRCode(result);
|
||||
_qrCode = SessionModel.extractQRCode(result);
|
||||
});
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@@ -105,18 +104,16 @@ class _LoginPageState extends State<LoginPage> {
|
||||
setState(() => _loading = true);
|
||||
|
||||
try {
|
||||
final result = await _apiService.login(_qrCode);
|
||||
final result = await SessionModel.instance.loginWithQr(_qrCode);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
if (result.success) {
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => HomePage(
|
||||
userId: result.userId,
|
||||
token: result.token,
|
||||
cookies: result.cookies,
|
||||
forcePreviewApi: _forcePreviewApi,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -147,4 +147,4 @@ class UserDataBean {
|
||||
banState: toInt(json['banState']),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,4 +87,4 @@ class UserPreviewDataBean {
|
||||
errorId: toInt(json['errorId']),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,7 @@ class ConfigExportImportPage extends StatelessWidget {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text(AppStrings.importSuccess)),
|
||||
);
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
} catch (_) {
|
||||
if (context.mounted) {
|
||||
|
||||
@@ -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
@@ -3,26 +3,25 @@ import 'package:flutter/material.dart';
|
||||
import '../config/responsive.dart';
|
||||
import '../config/strings.dart';
|
||||
import '../config/title_server_config.dart';
|
||||
import '../models/session_model.dart';
|
||||
import '../models/user_data.dart';
|
||||
import '../models/user_preview.dart';
|
||||
import '../services/title_api_service.dart';
|
||||
import 'about_page.dart';
|
||||
import 'high_risk_feature_page.dart';
|
||||
import 'settings_page.dart';
|
||||
import 'ticket_page.dart';
|
||||
import 'transfer_package_page.dart';
|
||||
|
||||
|
||||
class HomePage extends StatefulWidget {
|
||||
final int userId;
|
||||
final String token;
|
||||
final String? cookies;
|
||||
final bool forcePreviewApi;
|
||||
|
||||
const HomePage({
|
||||
super.key,
|
||||
required this.userId,
|
||||
required this.token,
|
||||
this.cookies,
|
||||
this.forcePreviewApi = false,
|
||||
});
|
||||
|
||||
@@ -34,16 +33,16 @@ class _HomePageState extends State<HomePage> {
|
||||
int _currentTab = 0;
|
||||
UserPreviewDataBean? _preview;
|
||||
UserDataBean? _userData;
|
||||
UserLoginResult? _login;
|
||||
String? _error;
|
||||
bool _loading = true;
|
||||
bool _loggingOut = false;
|
||||
String? _sessionCookies;
|
||||
|
||||
SessionModel get _session => SessionModel.instance;
|
||||
|
||||
static const _tabTitles = [
|
||||
AppStrings.tabHome,
|
||||
AppStrings.tabTickets,
|
||||
AppStrings.tabTransfer,
|
||||
AppStrings.tabRisk,
|
||||
AppStrings.tabSettings,
|
||||
AppStrings.tabAbout,
|
||||
];
|
||||
@@ -68,12 +67,12 @@ class _HomePageState extends State<HomePage> {
|
||||
_error = null;
|
||||
_preview = null;
|
||||
_userData = null;
|
||||
_login = null;
|
||||
});
|
||||
_session.setGameLogin(null);
|
||||
|
||||
final service = TitleApiService(
|
||||
TitleServerConfigHolder().config!,
|
||||
cookies: widget.cookies,
|
||||
cookies: _session.cookies,
|
||||
);
|
||||
|
||||
try {
|
||||
@@ -82,7 +81,7 @@ class _HomePageState extends State<HomePage> {
|
||||
token: widget.token,
|
||||
);
|
||||
if (!mounted) return;
|
||||
_sessionCookies = service.cookies;
|
||||
_session.updateCookies(service.cookies);
|
||||
setState(() => _preview = preview);
|
||||
|
||||
// isLogin == true => 已有人登录, 不再走完整登录, 直接展示 preview 概要
|
||||
@@ -92,17 +91,19 @@ class _HomePageState extends State<HomePage> {
|
||||
return;
|
||||
}
|
||||
|
||||
final login = await service.userLoginFull(
|
||||
await _session.loginGame(
|
||||
userId: widget.userId,
|
||||
token: widget.token,
|
||||
);
|
||||
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;
|
||||
_sessionCookies = service.cookies;
|
||||
_session.updateCookies(userDataService.cookies);
|
||||
setState(() {
|
||||
_userData = userData;
|
||||
_loading = false;
|
||||
@@ -129,23 +130,20 @@ class _HomePageState extends State<HomePage> {
|
||||
_error = null;
|
||||
});
|
||||
|
||||
final service = TitleApiService(
|
||||
TitleServerConfigHolder().config!,
|
||||
cookies: _sessionCookies ?? widget.cookies,
|
||||
);
|
||||
|
||||
try {
|
||||
final login = await service.userLoginFull(
|
||||
await _session.loginGame(
|
||||
userId: widget.userId,
|
||||
token: widget.token,
|
||||
);
|
||||
if (!mounted) return;
|
||||
_sessionCookies = service.cookies;
|
||||
setState(() => _login = login);
|
||||
|
||||
final service = TitleApiService(
|
||||
TitleServerConfigHolder().config!,
|
||||
cookies: _session.cookies,
|
||||
);
|
||||
final userData = await service.getUserDataTyped(widget.userId);
|
||||
if (!mounted) return;
|
||||
_sessionCookies = service.cookies;
|
||||
_session.updateCookies(service.cookies);
|
||||
setState(() {
|
||||
_userData = userData;
|
||||
_loading = false;
|
||||
@@ -166,30 +164,16 @@ class _HomePageState extends State<HomePage> {
|
||||
}
|
||||
|
||||
Future<void> _logoutSession() async {
|
||||
final login = _login;
|
||||
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;
|
||||
await _session.logoutGame(userId: widget.userId);
|
||||
}
|
||||
|
||||
Future<void> _performLogoutAndExit() async {
|
||||
setState(() => _loggingOut = true);
|
||||
if (_login != null) {
|
||||
if (_session.gameLogin != null) {
|
||||
await Future.delayed(const Duration(seconds: 5));
|
||||
await _logoutSession();
|
||||
}
|
||||
_session.reset();
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
@@ -199,61 +183,68 @@ class _HomePageState extends State<HomePage> {
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return PopScope(
|
||||
canPop: !_loggingOut,
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (!didPop && !_loggingOut) {
|
||||
_performLogoutAndExit();
|
||||
}
|
||||
return ListenableBuilder(
|
||||
listenable: _session,
|
||||
builder: (context, _) {
|
||||
return PopScope(
|
||||
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();
|
||||
}
|
||||
|
||||
if ((preview.isLogin || (widget.forcePreviewApi && _login == null)) && userData == null) {
|
||||
if ((preview.isLogin || (widget.forcePreviewApi && _session.gameLogin == null)) && userData == null) {
|
||||
return _buildPreviewOnly(
|
||||
theme,
|
||||
preview,
|
||||
showLoginButton: widget.forcePreviewApi && _login == null,
|
||||
showLoginButton: widget.forcePreviewApi && _session.gameLogin == null,
|
||||
onLoginPressed: _performLogin,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,10 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadConfig();
|
||||
}
|
||||
|
||||
void _loadConfig() {
|
||||
final config = TitleServerConfigHolder().config;
|
||||
if (config != null) {
|
||||
_urlController.text = config.titleServerUrl;
|
||||
@@ -155,10 +159,13 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
Navigator.of(context).push(
|
||||
onPressed: () async {
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const ConfigExportImportPage()),
|
||||
);
|
||||
if (mounted) {
|
||||
_loadConfig();
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.import_export, size: 20),
|
||||
label: const Text(AppStrings.configExportImport),
|
||||
|
||||
@@ -42,16 +42,16 @@ class TicketPage extends StatefulWidget {
|
||||
|
||||
class _TicketPageState extends State<TicketPage> {
|
||||
static const _ticketNameMap = {
|
||||
2: '2倍功能票',
|
||||
4: '4倍功能票',
|
||||
5: '5倍功能票'
|
||||
2: '2倍票',
|
||||
4: '4倍票',
|
||||
5: '5倍票'
|
||||
};
|
||||
|
||||
TicketStep _step = TicketStep.idle;
|
||||
String _stepMessage = '';
|
||||
String? _error;
|
||||
bool _running = false;
|
||||
bool _autoLogout = false;
|
||||
bool _autoLogout = true;
|
||||
|
||||
List<Map<String, dynamic>>? _tickets;
|
||||
bool _ticketsLoading = false;
|
||||
@@ -187,6 +187,15 @@ class _TicketPageState extends State<TicketPage> {
|
||||
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 service = TitleApiService(config, cookies: widget.cookies);
|
||||
final ticketId = _selectedTicketId!;
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -10,14 +10,12 @@ class LoginResult {
|
||||
final int errorId;
|
||||
final int userId;
|
||||
final String token;
|
||||
final String? cookies;
|
||||
|
||||
const LoginResult({
|
||||
required this.success,
|
||||
required this.errorId,
|
||||
required this.userId,
|
||||
required this.token,
|
||||
this.cookies,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -51,7 +49,7 @@ class ApiService {
|
||||
return sha256.convert(bytes).toString();
|
||||
}
|
||||
|
||||
String extractQRCode(String qrCodeToken) {
|
||||
static String extractQRCode(String qrCodeToken) {
|
||||
if (qrCodeToken.length > 64) {
|
||||
return qrCodeToken.substring(qrCodeToken.length - 64);
|
||||
}
|
||||
@@ -94,85 +92,14 @@ class ApiService {
|
||||
final userId = obj['userID'] as int? ?? -1;
|
||||
final token = obj['token'] as String? ?? '';
|
||||
|
||||
// ── Extract ALL cookies from response headers ──
|
||||
// On web, Set-Cookie is a forbidden response header (browser hides it).
|
||||
// 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');
|
||||
}
|
||||
// Session cookies (JSESSIONID) are captured from the title server's
|
||||
// UserLoginApi response, not from the Aime server — see empurple.
|
||||
|
||||
return LoginResult(
|
||||
success: errorId == 0,
|
||||
errorId: errorId,
|
||||
userId: userId,
|
||||
token: token,
|
||||
cookies: cleanCookies,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
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 {
|
||||
try {
|
||||
|
||||
@@ -93,46 +93,20 @@ class TitleApiService {
|
||||
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) {
|
||||
// ignore: avoid_print
|
||||
print('[$apiName] ── Response headers (${response.headers.length} entries) ──');
|
||||
final headerKeys = response.headers.keys.toList();
|
||||
String? rawCookies;
|
||||
for (final key in headerKeys) {
|
||||
if (!key.toLowerCase().contains('set-cookie')) continue;
|
||||
final value = response.headers[key]!;
|
||||
// ignore: avoid_print
|
||||
print('[$apiName] "$key": "$value"');
|
||||
final lower = key.toLowerCase();
|
||||
if (lower.contains('set-cookie') || lower.contains('cookie')) {
|
||||
rawCookies = value;
|
||||
final match =
|
||||
RegExp(r'JSESSIONID=([^;,]+)', caseSensitive: false).firstMatch(value);
|
||||
if (match != null) {
|
||||
_cookies = 'JSESSIONID=${match.group(1)!.trim()}';
|
||||
// ignore: avoid_print
|
||||
print('[$apiName] >>> Found cookie header: $key = $rawCookies');
|
||||
}
|
||||
}
|
||||
|
||||
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');
|
||||
print('[$apiName] >>> Captured session cookie: $_cookies');
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -184,14 +158,15 @@ class TitleApiService {
|
||||
// ignore: avoid_print
|
||||
print(packetStr);
|
||||
|
||||
final uaSuffix = userId != 0 ? '$userId' : _config.clientId;
|
||||
final headers = <String, String>{
|
||||
'User-Agent': '$hash#$userId',
|
||||
'User-Agent': '$hash#$uaSuffix',
|
||||
'Content-Type': 'application/json',
|
||||
'Mai-Encoding': _config.apiVersion,
|
||||
'Accept-Encoding': '',
|
||||
'Charset': 'UTF-8',
|
||||
'Content-Encoding': 'deflate',
|
||||
'Host': 'maimai-gm.wahlap.com:42081',
|
||||
'number': '0',
|
||||
};
|
||||
final cookies = _cookies;
|
||||
if (cookies != null) {
|
||||
@@ -307,7 +282,7 @@ class TitleApiService {
|
||||
'Accept-Encoding': '',
|
||||
'Charset': 'UTF-8',
|
||||
'Content-Encoding': 'deflate',
|
||||
'Host': 'maimai.wahlap.com:42081',
|
||||
'number': '0',
|
||||
};
|
||||
final previewCookies = _cookies;
|
||||
if (previewCookies != null) {
|
||||
@@ -434,6 +409,17 @@ class TitleApiService {
|
||||
|
||||
// ---- 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({
|
||||
required int userId,
|
||||
required int loginDateTime,
|
||||
@@ -451,16 +437,10 @@ class TitleApiService {
|
||||
};
|
||||
|
||||
final json = await _callApi(apiName, packet, userId);
|
||||
final returnCode = json['returnCode'] as int? ?? -1;
|
||||
if (returnCode != 1) {
|
||||
throw TitleApiException('UserLogoutApi returnCode=$returnCode');
|
||||
}
|
||||
_checkReturnCode(json, apiName);
|
||||
}
|
||||
|
||||
// ---- UpsertUserChargeLog (使用功能票) ----
|
||||
// Reference: KanadeBot SendTicketCommand / PayloadBuilder.generateTicketRequest
|
||||
|
||||
static const _ticketPriceMap = {1: 1, 2: 6, 3: 2, 4: 2, 5: 2, 6: 0};
|
||||
|
||||
Future<void> upsertUserChargeLog({
|
||||
required int userId,
|
||||
@@ -482,7 +462,7 @@ class TitleApiService {
|
||||
'userId': userId,
|
||||
'userChargelog': {
|
||||
'chargeId': ticketId,
|
||||
'price': _ticketPriceMap[ticketId] ?? 0,
|
||||
'price': 0,
|
||||
'purchaseDate': purchaseDateStr,
|
||||
'playCount': playCount,
|
||||
'playerRating': playerRating,
|
||||
@@ -500,10 +480,7 @@ class TitleApiService {
|
||||
};
|
||||
|
||||
final json = await _callApi(apiName, packet, userId);
|
||||
final returnCode = json['returnCode'] as int? ?? -1;
|
||||
if (returnCode != 1) {
|
||||
throw TitleApiException('UpsertUserChargelogApi returnCode=$returnCode');
|
||||
}
|
||||
_checkReturnCode(json, apiName);
|
||||
}
|
||||
|
||||
String _formatDateTime(DateTime dt) {
|
||||
@@ -523,10 +500,7 @@ class TitleApiService {
|
||||
'userId': userId,
|
||||
'userPlaylog': userPlaylog,
|
||||
}, userId);
|
||||
final returnCode = json['returnCode'] as int? ?? -1;
|
||||
if (returnCode != 1) {
|
||||
throw TitleApiException('UploadUserPlaylogApi returnCode=$returnCode');
|
||||
}
|
||||
_checkReturnCode(json, apiName);
|
||||
}
|
||||
|
||||
/// Fetch all user data needed to build an UpsertUserAll payload.
|
||||
@@ -560,10 +534,7 @@ class TitleApiService {
|
||||
) async {
|
||||
const apiName = 'UpsertUserAllApi';
|
||||
final json = await _callApi(apiName, packet, userId);
|
||||
final returnCode = json['returnCode'] as int? ?? -1;
|
||||
if (returnCode != 1) {
|
||||
throw TitleApiException('UpsertUserAllApi returnCode=$returnCode');
|
||||
}
|
||||
_checkReturnCode(json, apiName);
|
||||
}
|
||||
|
||||
/// Fetch user character list.
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,10 @@ project(runner LANGUAGES CXX)
|
||||
|
||||
# The name of the executable created for the application. Change this to change
|
||||
# the on-disk name of your application.
|
||||
set(BINARY_NAME "ichino")
|
||||
set(BINARY_NAME "purarine")
|
||||
# The unique GTK application identifier for this application. See:
|
||||
# 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
|
||||
# versions of CMake.
|
||||
|
||||
@@ -45,11 +45,11 @@ static void my_application_activate(GApplication* application) {
|
||||
if (use_header_bar) {
|
||||
GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new());
|
||||
gtk_widget_show(GTK_WIDGET(header_bar));
|
||||
gtk_header_bar_set_title(header_bar, "ichino");
|
||||
gtk_header_bar_set_title(header_bar, "Purarine");
|
||||
gtk_header_bar_set_show_close_button(header_bar, TRUE);
|
||||
gtk_window_set_titlebar(window, GTK_WIDGET(header_bar));
|
||||
} else {
|
||||
gtk_window_set_title(window, "ichino");
|
||||
gtk_window_set_title(window, "Purarine");
|
||||
}
|
||||
|
||||
gtk_window_set_default_size(window, 1280, 720);
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
|
||||
333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = "<group>"; };
|
||||
335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = "<group>"; };
|
||||
33CC10ED2044A3C60003C045 /* 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>"; };
|
||||
33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = "<group>"; };
|
||||
33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = "<group>"; };
|
||||
@@ -134,7 +134,7 @@
|
||||
33CC10EE2044A3C60003C045 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
33CC10ED2044A3C60003C045 /* ichino.app */,
|
||||
33CC10ED2044A3C60003C045 /* Purarine.app */,
|
||||
331C80D5294CF71000263BE5 /* RunnerTests.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
@@ -224,7 +224,7 @@
|
||||
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
|
||||
);
|
||||
productName = Runner;
|
||||
productReference = 33CC10ED2044A3C60003C045 /* ichino.app */;
|
||||
productReference = 33CC10ED2044A3C60003C045 /* Purarine.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
@@ -395,10 +395,10 @@
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.ichino.RunnerTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = dev.naominet.purarine.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
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;
|
||||
};
|
||||
@@ -409,10 +409,10 @@
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.ichino.RunnerTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = dev.naominet.purarine.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
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;
|
||||
};
|
||||
@@ -423,10 +423,10 @@
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.ichino.RunnerTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = dev.naominet.purarine.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "33CC10EC2044A3C60003C045"
|
||||
BuildableName = "ichino.app"
|
||||
BuildableName = "Purarine.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
@@ -33,7 +33,7 @@
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "33CC10EC2044A3C60003C045"
|
||||
BuildableName = "ichino.app"
|
||||
BuildableName = "Purarine.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
@@ -49,7 +49,7 @@
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "33CC10EC2044A3C60003C045"
|
||||
BuildableName = "ichino.app"
|
||||
BuildableName = "Purarine.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
@@ -84,7 +84,7 @@
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "33CC10EC2044A3C60003C045"
|
||||
BuildableName = "ichino.app"
|
||||
BuildableName = "Purarine.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
@@ -101,7 +101,7 @@
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "33CC10EC2044A3C60003C045"
|
||||
BuildableName = "ichino.app"
|
||||
BuildableName = "Purarine.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
// 'flutter create' template.
|
||||
|
||||
// The application's name. By default this is also the title of the Flutter window.
|
||||
PRODUCT_NAME = ichino
|
||||
PRODUCT_NAME = Purarine
|
||||
|
||||
// The application's bundle identifier
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.ichino
|
||||
PRODUCT_BUNDLE_IDENTIFIER = dev.naominet.purarine
|
||||
|
||||
// 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
@@ -308,10 +308,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: matcher
|
||||
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
||||
sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.19"
|
||||
version: "0.12.20"
|
||||
material_color_utilities:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -324,10 +324,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
||||
sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.18.0"
|
||||
version: "1.19.0"
|
||||
mime:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -505,10 +505,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
||||
sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.11"
|
||||
version: "0.7.12"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -521,10 +521,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_math
|
||||
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
|
||||
sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
version: "2.4.2"
|
||||
vm_service:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
name: ichino
|
||||
name: purarine
|
||||
description: "A new Flutter project."
|
||||
# The following line prevents the package from being accidentally published to
|
||||
# pub.dev using `flutter pub publish`. This is preferred for private packages.
|
||||
|
||||
+4
-20
@@ -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_test/flutter_test.dart';
|
||||
|
||||
import 'package:ichino/main.dart';
|
||||
import 'package:purarine/main.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
|
||||
// Build our app and trigger a frame.
|
||||
testWidgets('App renders login page with app title', (WidgetTester tester) async {
|
||||
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();
|
||||
|
||||
// Verify that our counter has incremented.
|
||||
expect(find.text('0'), findsNothing);
|
||||
expect(find.text('1'), findsOneWidget);
|
||||
expect(find.text('Purarine'), findsWidgets);
|
||||
expect(find.byType(MaterialApp), findsOneWidget);
|
||||
});
|
||||
}
|
||||
+3
-3
@@ -5,14 +5,14 @@
|
||||
|
||||
<meta charset="UTF-8">
|
||||
<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="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="icon" type="image/png" href="favicon.png"/>
|
||||
|
||||
<title>Ichino</title>
|
||||
<title>Purarine</title>
|
||||
<link rel="manifest" href="manifest.json">
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/jsQR.js"></script>
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ichino",
|
||||
"short_name": "ichino",
|
||||
"name": "Purarine",
|
||||
"short_name": "Purarine",
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"background_color": "#0175C2",
|
||||
|
||||
Whitespace-only changes.
@@ -1,10 +1,10 @@
|
||||
# Project-level configuration.
|
||||
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 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
|
||||
# versions of CMake.
|
||||
|
||||
@@ -89,13 +89,13 @@ BEGIN
|
||||
BEGIN
|
||||
BLOCK "040904e4"
|
||||
BEGIN
|
||||
VALUE "CompanyName", "com.example" "\0"
|
||||
VALUE "FileDescription", "ichino" "\0"
|
||||
VALUE "CompanyName", "dev.naominet" "\0"
|
||||
VALUE "FileDescription", "Purarine" "\0"
|
||||
VALUE "FileVersion", VERSION_AS_STRING "\0"
|
||||
VALUE "InternalName", "ichino" "\0"
|
||||
VALUE "LegalCopyright", "Copyright (C) 2026 com.example. All rights reserved." "\0"
|
||||
VALUE "OriginalFilename", "ichino.exe" "\0"
|
||||
VALUE "ProductName", "ichino" "\0"
|
||||
VALUE "InternalName", "purarine" "\0"
|
||||
VALUE "LegalCopyright", "Copyright (C) 2026 dev.naominet. All rights reserved." "\0"
|
||||
VALUE "OriginalFilename", "purarine.exe" "\0"
|
||||
VALUE "ProductName", "Purarine" "\0"
|
||||
VALUE "ProductVersion", VERSION_AS_STRING "\0"
|
||||
END
|
||||
END
|
||||
|
||||
@@ -27,7 +27,7 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev,
|
||||
FlutterWindow window(project);
|
||||
Win32Window::Point origin(10, 10);
|
||||
Win32Window::Size size(1280, 720);
|
||||
if (!window.Create(L"ichino", origin, size)) {
|
||||
if (! window.Create(L"Purarine", origin, size)) {
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
window.SetQuitOnClose(true);
|
||||
|
||||
Reference in new issue
Block a user