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

No files matched your search

+69 -55
View File
@@ -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
View File
@@ -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,
),
),
+90
View File
@@ -0,0 +1,90 @@
import 'package:flutter/foundation.dart';
import '../config/title_server_config.dart';
import '../services/api_service.dart';
import '../services/title_api_service.dart';
class SessionModel extends ChangeNotifier {
static final SessionModel instance = SessionModel._();
SessionModel._();
final ApiService _apiService = ApiService();
LoginResult? _loginResult;
UserLoginResult? _gameLogin;
String? _cookies;
LoginResult? get loginResult => _loginResult;
UserLoginResult? get gameLogin => _gameLogin;
String? get cookies => _cookies;
static String extractQRCode(String qrCodeToken) =>
ApiService.extractQRCode(qrCodeToken);
Future<LoginResult> loginWithQr(String qrCodeToken) async {
final result = await _apiService.login(qrCodeToken);
if (result.success) {
_loginResult = result;
notifyListeners();
}
return result;
}
Future<UserLoginResult> loginGame({
required int userId,
required String token,
}) async {
final config = TitleServerConfigHolder().config;
if (config == null) {
throw const TitleApiException('Title Server 未配置');
}
// Session cookie comes from the UserLoginApi response itself (empurple).
final service = TitleApiService(config);
final login = await service.userLoginFull(userId: userId, token: token);
updateCookies(service.cookies);
_gameLogin = login;
notifyListeners();
return login;
}
Future<void> logoutGame({
required int userId,
String? cookies,
}) async {
final login = _gameLogin;
if (login == null) return;
final config = TitleServerConfigHolder().config;
if (config == null) return;
final service = TitleApiService(config, cookies: cookies ?? _cookies);
try {
await service.userLogout(
userId: userId,
loginDateTime: login.loginDateTime,
);
} catch (_) {
// best-effort
}
_gameLogin = null;
notifyListeners();
}
void setGameLogin(UserLoginResult? login) {
_gameLogin = login;
notifyListeners();
}
void updateCookies(String? cookies) {
if (cookies != null && cookies.isNotEmpty) {
_cookies = cookies;
}
}
void reset() {
_loginResult = null;
_gameLogin = null;
_cookies = null;
notifyListeners();
}
}
-38
View File
@@ -1,38 +0,0 @@
/// The full UpsertUserAll API payload, constructed from fetched user data
/// and an editable single-song score.
class UpsertUserAllPayload {
final int userId;
final int playlogId;
final bool isEventMode;
final bool isFreePlay;
final int loginDateTime;
/// The entire [upsertUserAll] sub-object from the JSON.
final Map<String, dynamic> upsertUserAll;
/// Minimal playlog entry (required — server rejects empty playlogList).
final Map<String, dynamic>? userPlaylog;
const UpsertUserAllPayload({
required this.userId,
required this.playlogId,
this.isEventMode = false,
this.isFreePlay = false,
required this.loginDateTime,
required this.upsertUserAll,
this.userPlaylog,
});
/// Serialize to the JSON packet expected by UpsertUserAllApi.
Map<String, dynamic> toJson() {
return {
'userId': userId,
'playlogId': playlogId,
'isEventMode': isEventMode,
'isFreePlay': isFreePlay,
'loginDateTime': loginDateTime,
'userPlaylogList': userPlaylog != null ? [userPlaylog] : <dynamic>[],
'upsertUserAll': upsertUserAll,
};
}
}
+1 -1
View File
@@ -147,4 +147,4 @@ class UserDataBean {
banState: toInt(json['banState']),
);
}
}
}
+1 -1
View File
@@ -87,4 +87,4 @@ class UserPreviewDataBean {
errorId: toInt(json['errorId']),
);
}
}
}
+47
View File
@@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
import '../config/strings.dart';
import 'music_risk_feature_view.dart';
/// 收藏品获取:逻辑参考 `eaquira/action/UnlockMusic.py`,
/// 通过上传 userItemList 道具获取收藏品 (UpsertUserAllApi)。
///
/// itemKind: 1=姓名框 2=称号 3=头像 10=搭档 11=背景板 12=功能票
class CollectiblesPage extends StatelessWidget {
final int userId;
final String? cookies;
final int? loginDateTime;
final int? loginId;
final String? lastLoginDate;
final Future<void> Function()? onLogoutRequested;
const CollectiblesPage({
super.key,
required this.userId,
this.cookies,
this.loginDateTime,
this.loginId,
this.lastLoginDate,
this.onLogoutRequested,
});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text(AppStrings.collectiblesFeatureTitle),
),
body: MusicRiskFeatureView(
userId: userId,
cookies: cookies,
loginDateTime: loginDateTime,
loginId: loginId,
lastLoginDate: lastLoginDate,
onLogoutRequested: onLogoutRequested,
mode: MusicRiskFeatureMode.collectibles,
featureTitle: AppStrings.collectiblesFeatureTitle,
featureDesc: AppStrings.collectiblesFeatureDesc,
),
);
}
}
+1
View File
@@ -54,6 +54,7 @@ class ConfigExportImportPage extends StatelessWidget {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text(AppStrings.importSuccess)),
);
Navigator.of(context).pop();
}
} catch (_) {
if (context.mounted) {
+327
View File
@@ -0,0 +1,327 @@
import 'dart:async';
import 'package:flutter/material.dart';
import '../config/responsive.dart';
import '../config/strings.dart';
import '../config/title_server_config.dart';
import 'collectibles_page.dart';
import 'unlock_music_page.dart';
/// 高危功能中转页:导航到 UnlockMusic (解锁歌曲) / 收藏品获取。
/// 两个 feature 互相独立,各自的执行流程互不影响。
class HighRiskFeaturePage extends StatefulWidget {
final int userId;
final String? cookies;
/// `loginDateTime` 来自 HomePage 启动时执行的 UserLoginApi。
/// 为 `null` 时表示当前未持有有效登录态。
final int? loginDateTime;
/// UserLoginApi 返回的 loginId / lastLoginDate, 组装 UserAll 需要。
final int? loginId;
final String? lastLoginDate;
/// 由 HomePage 负责发送 logout 包。
final Future<void> Function()? onLogoutRequested;
const HighRiskFeaturePage({
super.key,
required this.userId,
this.cookies,
this.loginDateTime,
this.loginId,
this.lastLoginDate,
this.onLogoutRequested,
});
@override
State<HighRiskFeaturePage> createState() => _HighRiskFeaturePageState();
}
class _HighRiskFeaturePageState extends State<HighRiskFeaturePage> {
Timer? _cooldownTimer;
int _cooldownRemaining = 0;
@override
void initState() {
super.initState();
_syncCooldown();
}
@override
void didUpdateWidget(covariant HighRiskFeaturePage oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.loginDateTime != widget.loginDateTime) {
_syncCooldown();
}
}
@override
void dispose() {
_cooldownTimer?.cancel();
super.dispose();
}
void _syncCooldown() {
_cooldownTimer?.cancel();
final loginDateTime = widget.loginDateTime;
if (loginDateTime == null) {
_cooldownRemaining = 0;
return;
}
_cooldownRemaining = _computeRemaining(loginDateTime);
if (_cooldownRemaining <= 0) return;
_cooldownTimer = Timer.periodic(const Duration(seconds: 1), (_) {
if (!mounted) return;
final remaining = _computeRemaining(loginDateTime);
setState(() => _cooldownRemaining = remaining);
if (remaining <= 0) {
_cooldownTimer?.cancel();
_cooldownTimer = null;
}
});
}
int _computeRemaining(int loginDateTime) {
final nowSec = DateTime.now().millisecondsSinceEpoch ~/ 1000;
final elapsed = nowSec - loginDateTime;
final remaining = AppStrings.ticketCooldownSeconds - elapsed;
return remaining < 0 ? 0 : remaining;
}
void _openUnlockMusic() {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => UnlockMusicPage(
userId: widget.userId,
cookies: widget.cookies,
loginDateTime: widget.loginDateTime,
loginId: widget.loginId,
lastLoginDate: widget.lastLoginDate,
onLogoutRequested: widget.onLogoutRequested,
),
),
);
}
void _openCollectibles() {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => CollectiblesPage(
userId: widget.userId,
cookies: widget.cookies,
loginDateTime: widget.loginDateTime,
loginId: widget.loginId,
lastLoginDate: widget.lastLoginDate,
onLogoutRequested: widget.onLogoutRequested,
),
),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
if (!TitleServerConfigHolder().isConfigured) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.settings_ethernet,
size: 48, color: theme.colorScheme.primary),
const SizedBox(height: 16),
Text(AppStrings.ticketNotConfigured,
style: theme.textTheme.titleMedium),
],
),
),
);
}
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: responsiveMaxWidth(context)),
child: Column(
children: [
if (widget.loginDateTime == null) _buildNotLoggedInBanner(theme),
if (widget.loginDateTime != null && _cooldownRemaining > 0)
_buildCooldownBanner(theme),
_buildDescCard(theme),
const SizedBox(height: 12),
_buildNavCard(
theme,
icon: Icons.lock_open,
title: AppStrings.unlockFeatureTitle,
desc: AppStrings.unlockFeatureDesc,
onTap: _openUnlockMusic,
),
const SizedBox(height: 12),
_buildNavCard(
theme,
icon: Icons.card_giftcard,
title: AppStrings.collectiblesFeatureTitle,
desc: AppStrings.collectiblesFeatureDesc,
onTap: _openCollectibles,
),
],
),
),
);
}
Widget _buildNotLoggedInBanner(ThemeData theme) {
return Container(
width: double.infinity,
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: theme.colorScheme.errorContainer,
borderRadius: BorderRadius.circular(10),
),
child: Row(
children: [
Icon(Icons.warning_amber_rounded,
size: 18, color: theme.colorScheme.onErrorContainer),
const SizedBox(width: 8),
Expanded(
child: Text(
AppStrings.musicRiskHubNotLoggedIn,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onErrorContainer,
),
),
),
],
),
);
}
Widget _buildCooldownBanner(ThemeData theme) {
return Container(
width: double.infinity,
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: theme.colorScheme.tertiaryContainer.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(10),
),
child: Row(
children: [
Icon(Icons.timer_outlined,
size: 18, color: theme.colorScheme.onTertiaryContainer),
const SizedBox(width: 8),
Expanded(
child: Text(
AppStrings.musicRiskHubCooldownNotice(_cooldownRemaining),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onTertiaryContainer,
),
),
),
],
),
);
}
Widget _buildDescCard(ThemeData theme) {
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: theme.colorScheme.error.withValues(alpha: 0.4),
),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.warning_amber_rounded,
size: 18, color: theme.colorScheme.error),
const SizedBox(width: 8),
Expanded(
child: Text(
AppStrings.musicRiskHubTitle,
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.error,
fontWeight: FontWeight.w600,
),
),
),
],
),
const SizedBox(height: 12),
Text(
AppStrings.musicRiskHubDesc,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
);
}
Widget _buildNavCard(
ThemeData theme, {
required IconData icon,
required String title,
required String desc,
required VoidCallback onTap,
}) {
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: theme.colorScheme.outline.withValues(alpha: 0.3)),
),
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(20),
child: Row(
children: [
Icon(icon, size: 28, color: theme.colorScheme.primary),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
Text(
desc,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
const SizedBox(width: 8),
Icon(
Icons.chevron_right,
color: theme.colorScheme.onSurfaceVariant,
),
],
),
),
),
);
}
}
+87 -96
View File
@@ -3,26 +3,25 @@ import 'package:flutter/material.dart';
import '../config/responsive.dart';
import '../config/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,
);
}
+914
View File
@@ -0,0 +1,914 @@
import 'dart:async';
import 'package:flutter/material.dart';
import '../config/responsive.dart';
import '../config/strings.dart';
import '../config/title_server_config.dart';
import '../services/title_api_service.dart';
import '../services/user_all_payload_builder.dart';
/// Dart port of `eaquira/action/UnlockMusic.py`:
///
/// - 拉取 GetUserData/Extend/Option/Rating/Charge/Activity/MissionData
/// - 组装 UserAll payload (musicData 使用 eaquira settings 中的默认值)
/// - 附加道具 (userItemList / isNewItemList)
/// - UpsertUserAllApi 上传
/// - 可选自动 UserLogout
enum MusicRiskStep { idle, fetchData, upload, logout, complete, failed }
enum MusicRiskFeatureMode {
/// 解锁歌曲与谱面 (itemKind 5/6/7)
unlockMusic,
/// 收藏品获取 (itemKind 1/2/3/10/11/12)
collectibles,
}
class MusicRiskFeatureView extends StatefulWidget {
final int userId;
final String? cookies;
/// `loginDateTime` 来自 HomePage 启动时执行的 UserLoginApi。
/// 为 `null` 时表示当前未持有有效登录态(例如 isInherit 账号或登录失败)。
final int? loginDateTime;
/// UserLoginApi 返回的 loginId / lastLoginDate, 组装 UserAll 需要。
final int? loginId;
final String? lastLoginDate;
/// 当用户勾选"完成后自动退出登录"时,由 HomePage 负责发送 logout 包。
final Future<void> Function()? onLogoutRequested;
final MusicRiskFeatureMode mode;
final String featureTitle;
final String featureDesc;
const MusicRiskFeatureView({
super.key,
required this.userId,
this.cookies,
this.loginDateTime,
this.loginId,
this.lastLoginDate,
this.onLogoutRequested,
this.mode = MusicRiskFeatureMode.unlockMusic,
required this.featureTitle,
required this.featureDesc,
});
@override
State<MusicRiskFeatureView> createState() => _MusicRiskFeatureViewState();
}
class _MusicRiskFeatureViewState extends State<MusicRiskFeatureView> {
/// eaquira `settings.musicData` 默认歌曲 (Amber Chronicle)。
static const int _defaultMusicId = 11538;
// ── 解锁选项 (UnlockMusic) ──
final _unlockMusicIdController = TextEditingController();
bool _unlockMusic = false;
bool _unlockMaster = false;
bool _unlockRemaster = false;
// ── 收藏品选项 (Collectibles) ──
final _itemIdController = TextEditingController();
int _itemKind = 3;
// ── 运行状态 ──
Map<String, Map<String, dynamic>>? _userAllData;
bool _fetching = false;
String? _fetchError;
bool _running = false;
bool _autoLogout = false;
MusicRiskStep _step = MusicRiskStep.idle;
String _stepMessage = '';
String? _error;
// ── 冷却 ──
Timer? _cooldownTimer;
int _cooldownRemaining = 0;
bool get _isUnlock => widget.mode == MusicRiskFeatureMode.unlockMusic;
@override
void initState() {
super.initState();
_syncCooldown();
}
@override
void didUpdateWidget(covariant MusicRiskFeatureView oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.loginDateTime != widget.loginDateTime) {
_syncCooldown();
}
}
@override
void dispose() {
_cooldownTimer?.cancel();
_unlockMusicIdController.dispose();
_itemIdController.dispose();
super.dispose();
}
void _syncCooldown() {
_cooldownTimer?.cancel();
final loginDateTime = widget.loginDateTime;
if (loginDateTime == null) {
_cooldownRemaining = 0;
return;
}
_cooldownRemaining = _computeRemaining(loginDateTime);
if (_cooldownRemaining <= 0) return;
_cooldownTimer = Timer.periodic(const Duration(seconds: 1), (_) {
if (!mounted) return;
final remaining = _computeRemaining(loginDateTime);
setState(() => _cooldownRemaining = remaining);
if (remaining <= 0) {
_cooldownTimer?.cancel();
_cooldownTimer = null;
}
});
}
int _computeRemaining(int loginDateTime) {
final nowSec = DateTime.now().millisecondsSinceEpoch ~/ 1000;
final elapsed = nowSec - loginDateTime;
final remaining = AppStrings.ticketCooldownSeconds - elapsed;
return remaining < 0 ? 0 : remaining;
}
void _updateStep(MusicRiskStep step, [String? message]) {
if (!mounted) return;
setState(() {
_step = step;
_stepMessage = message ?? '';
});
}
int _unlockMusicId() =>
int.tryParse(_unlockMusicIdController.text.trim()) ?? -1;
int _itemId() => int.tryParse(_itemIdController.text.trim()) ?? -1;
/// eaquira UnlockMusic.py 使用 settings 中的默认 musicData。
Map<String, dynamic> _buildMusicData() {
return {
'musicId': _isUnlock ? _unlockMusicId() : _defaultMusicId,
'level': 0,
'playCount': 1,
'achievement': 0,
'comboStatus': 0,
'syncStatus': 0,
'deluxscoreMax': 0,
'scoreRank': 0,
'extNum1': 0,
};
}
String? _validateInputs() {
if (_isUnlock) {
if (_unlockMusicId() <= 0) return AppStrings.unlockNeedMusicId;
if (!_unlockMusic && !_unlockMaster && !_unlockRemaster) {
return AppStrings.unlockNeedOption;
}
return null;
}
if (_itemId() <= 0) return AppStrings.collectiblesNeedItemId;
return null;
}
Future<void> _fetchData() async {
if (!TitleServerConfigHolder().isConfigured) return;
setState(() {
_fetching = true;
_fetchError = null;
_userAllData = null;
});
try {
final config = TitleServerConfigHolder().config!;
final service = TitleApiService(config, cookies: widget.cookies);
final data = await service.fetchUserAllData(widget.userId);
if (!mounted) return;
setState(() {
_userAllData = data;
_fetching = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_fetchError = e.toString();
_fetching = false;
});
}
}
Future<void> _run() async {
final validationError = _validateInputs();
if (validationError != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(validationError)),
);
return;
}
if (!TitleServerConfigHolder().isConfigured) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text(AppStrings.ticketNotConfigured)),
);
return;
}
final loginDateTime = widget.loginDateTime;
final loginId = widget.loginId;
if (loginDateTime == null || loginId == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(_notLoggedInMessage)),
);
return;
}
if (_cooldownRemaining > 0) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(_cooldownNotice(_cooldownRemaining))),
);
return;
}
final config = TitleServerConfigHolder().config!;
final service = TitleApiService(config, cookies: widget.cookies);
setState(() {
_running = true;
_error = null;
});
try {
Map<String, Map<String, dynamic>> data = _userAllData ?? const {};
if (data.isEmpty) {
_updateStep(MusicRiskStep.fetchData);
data = await service.fetchUserAllData(widget.userId);
if (mounted) setState(() => _userAllData = data);
}
_updateStep(MusicRiskStep.upload);
final musicData = _buildMusicData();
final builder = UserAllPayloadBuilder(config);
final packet = builder.build(
userId: widget.userId,
loginId: loginId,
loginDateTime: loginDateTime,
musicData: musicData,
generalUserInfo: data,
);
if (_isUnlock) {
builder.applyMusicUnlockPatch(
packet,
musicData: musicData,
musicId: _unlockMusicId(),
unlockMusic: _unlockMusic,
unlockMaster: _unlockMaster,
unlockRemaster: _unlockRemaster,
);
} else {
builder.applyItemListPatch(packet, items: [
{'itemKind': _itemKind, 'itemId': _itemId(), 'stock': 1, 'isValid': true},
]);
}
await service.upsertUserAll(packet, widget.userId);
if (_autoLogout && widget.onLogoutRequested != null) {
_updateStep(MusicRiskStep.logout);
await Future.delayed(const Duration(seconds: 5));
await widget.onLogoutRequested!();
}
_updateStep(
MusicRiskStep.complete,
_isUnlock
? AppStrings.unlockMusicSuccess
: AppStrings.collectiblesSuccess,
);
} on TitleApiException catch (e) {
_updateStep(MusicRiskStep.failed, e.message);
setState(() => _error = e.message);
} catch (e) {
_updateStep(MusicRiskStep.failed, e.toString());
setState(() => _error = e.toString());
} finally {
if (mounted) setState(() => _running = false);
}
}
String get _notLoggedInMessage => _isUnlock
? AppStrings.unlockNotLoggedIn
: AppStrings.collectiblesNotLoggedIn;
String _cooldownNotice(int remaining) => _isUnlock
? AppStrings.unlockCooldownNotice(remaining)
: AppStrings.collectiblesCooldownNotice(remaining);
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
if (!TitleServerConfigHolder().isConfigured) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.settings_ethernet,
size: 48, color: theme.colorScheme.primary),
const SizedBox(height: 16),
Text(AppStrings.ticketNotConfigured,
style: theme.textTheme.titleMedium),
],
),
),
);
}
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: responsiveMaxWidth(context)),
child: Column(
children: [
if (widget.loginDateTime == null) _buildNotLoggedInBanner(theme),
if (widget.loginDateTime != null && _cooldownRemaining > 0)
_buildCooldownBanner(theme),
_buildDescCard(theme),
const SizedBox(height: 12),
if (_isUnlock)
_buildUnlockCard(theme)
else
_buildCollectiblesCard(theme),
const SizedBox(height: 12),
_buildFetchCard(theme),
const SizedBox(height: 12),
_buildAutoLogoutToggle(theme),
const SizedBox(height: 12),
_buildRunButton(theme),
if (_step != MusicRiskStep.idle) ...[
const SizedBox(height: 16),
_buildProgressCard(theme),
],
],
),
),
);
}
Widget _buildNotLoggedInBanner(ThemeData theme) {
return Container(
width: double.infinity,
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: theme.colorScheme.errorContainer,
borderRadius: BorderRadius.circular(10),
),
child: Row(
children: [
Icon(Icons.warning_amber_rounded,
size: 18, color: theme.colorScheme.onErrorContainer),
const SizedBox(width: 8),
Expanded(
child: Text(
_notLoggedInMessage,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onErrorContainer,
),
),
),
],
),
);
}
Widget _buildCooldownBanner(ThemeData theme) {
return Container(
width: double.infinity,
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: theme.colorScheme.tertiaryContainer.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(10),
),
child: Row(
children: [
Icon(Icons.timer_outlined,
size: 18, color: theme.colorScheme.onTertiaryContainer),
const SizedBox(width: 8),
Expanded(
child: Text(
_cooldownNotice(_cooldownRemaining),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onTertiaryContainer,
),
),
),
],
),
);
}
Widget _buildDescCard(ThemeData theme) {
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: theme.colorScheme.outline.withValues(alpha: 0.3)),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
_isUnlock ? Icons.lock_open : Icons.card_giftcard,
size: 18,
color: theme.colorScheme.primary,
),
const SizedBox(width: 8),
Expanded(
child: Text(
widget.featureTitle,
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
),
],
),
const SizedBox(height: 12),
Text(
widget.featureDesc,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
);
}
Widget _buildUnlockCard(ThemeData theme) {
final enabled = !_running;
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: theme.colorScheme.outline.withValues(alpha: 0.3)),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: _unlockMusicIdController,
enabled: enabled,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: AppStrings.unlockMusicIdLabel,
hintText: AppStrings.unlockMusicIdHint,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
),
contentPadding: const EdgeInsets.all(14),
),
),
const SizedBox(height: 6),
_buildUnlockCheckbox(
theme,
value: _unlockMusic,
label: AppStrings.unlockMusicOption,
enabled: enabled,
onChanged: (v) => setState(() => _unlockMusic = v),
),
_buildUnlockCheckbox(
theme,
value: _unlockMaster,
label: AppStrings.unlockMasterOption,
enabled: enabled,
onChanged: (v) => setState(() => _unlockMaster = v),
),
_buildUnlockCheckbox(
theme,
value: _unlockRemaster,
label: AppStrings.unlockRemasterOption,
enabled: enabled,
onChanged: (v) => setState(() => _unlockRemaster = v),
),
],
),
),
);
}
Widget _buildUnlockCheckbox(
ThemeData theme, {
required bool value,
required String label,
required bool enabled,
required ValueChanged<bool> onChanged,
}) {
return InkWell(
borderRadius: BorderRadius.circular(10),
onTap: enabled ? () => onChanged(!value) : null,
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
child: Row(
children: [
Checkbox(
value: value,
onChanged: enabled ? (v) => onChanged(v ?? false) : null,
),
Expanded(
child: Text(
label,
style: theme.textTheme.bodyMedium?.copyWith(
color: enabled
? null
: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
),
),
),
],
),
),
);
}
Widget _buildCollectiblesCard(ThemeData theme) {
final enabled = !_running;
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: theme.colorScheme.outline.withValues(alpha: 0.3)),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildDropdown(
value: _itemKind,
label: AppStrings.collectiblesItemKindLabel,
items: [
for (final kind in AppStrings.collectiblesItemKinds)
DropdownMenuItem(
value: kind,
child: Text(
'${AppStrings.collectiblesItemKindName(kind)} ($kind)',
),
),
],
onChanged: (v) => setState(() => _itemKind = v),
),
const SizedBox(height: 12),
TextField(
controller: _itemIdController,
enabled: enabled,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: AppStrings.collectiblesItemIdLabel,
hintText: AppStrings.collectiblesItemIdHint,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
),
contentPadding: const EdgeInsets.all(14),
),
),
],
),
),
);
}
Widget _buildFetchCard(ThemeData theme) {
final hasData = _userAllData != null;
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: hasData
? Colors.green.withValues(alpha: 0.5)
: theme.colorScheme.outline.withValues(alpha: 0.3),
),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
hasData ? Icons.check_circle : Icons.download,
size: 18,
color: hasData ? Colors.green : theme.colorScheme.primary,
),
const SizedBox(width: 8),
Expanded(
child: Text(
hasData
? AppStrings.unlockFetched
: AppStrings.unlockNoData,
style: theme.textTheme.bodySmall?.copyWith(
color: hasData
? Colors.green
: theme.colorScheme.onSurfaceVariant,
),
),
),
SizedBox(
height: 32,
child: OutlinedButton.icon(
onPressed: _fetching || _running ? null : _fetchData,
icon: _fetching
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.refresh, size: 16),
label: Text(
_fetching
? AppStrings.unlockFetching
: hasData
? AppStrings.unlockRefetch
: AppStrings.unlockFetchData,
style: theme.textTheme.labelSmall,
),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
),
],
),
if (_fetchError != null) ...[
const SizedBox(height: 12),
Container(
width: double.infinity,
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: theme.colorScheme.errorContainer,
borderRadius: BorderRadius.circular(8),
),
child: Text(
_fetchError!,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onErrorContainer,
fontFamily: 'monospace',
),
),
),
],
],
),
),
);
}
Widget _buildAutoLogoutToggle(ThemeData theme) {
final enabled = widget.onLogoutRequested != null && !_running;
return InkWell(
borderRadius: BorderRadius.circular(10),
onTap: enabled
? () => setState(() => _autoLogout = !_autoLogout)
: null,
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: Row(
children: [
Checkbox(
value: _autoLogout,
onChanged: enabled
? (v) => setState(() => _autoLogout = v ?? false)
: null,
),
Expanded(
child: Text(
AppStrings.unlockAutoLogout,
style: theme.textTheme.bodyMedium?.copyWith(
color: enabled
? null
: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
),
),
),
],
),
),
);
}
Widget _buildRunButton(ThemeData theme) {
final onCooldown = _cooldownRemaining > 0;
final canRun = widget.loginDateTime != null &&
widget.loginId != null &&
!_running &&
!onCooldown;
return SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: canRun ? _run : null,
icon: _running
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
)
: Icon(
onCooldown
? Icons.timer_outlined
: (_isUnlock ? Icons.lock_open : Icons.card_giftcard),
size: 20,
),
label: Text(
_running
? AppStrings.unlockRunning
: onCooldown
? AppStrings.ticketCooldownCountdown(_cooldownRemaining)
: _isUnlock
? AppStrings.unlockMusicRun
: AppStrings.collectiblesRun,
),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
);
}
Widget _buildProgressCard(ThemeData theme) {
final isFailed = _step == MusicRiskStep.failed;
final isDone = _step == MusicRiskStep.complete;
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: isFailed
? theme.colorScheme.error.withValues(alpha: 0.5)
: isDone
? Colors.green.withValues(alpha: 0.5)
: theme.colorScheme.outline.withValues(alpha: 0.3),
),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_stepRow(MusicRiskStep.fetchData, AppStrings.unlockStepFetch, theme),
_stepRow(
MusicRiskStep.upload,
_isUnlock
? AppStrings.unlockStepUpload
: AppStrings.collectiblesStepUpload,
theme,
),
if (_autoLogout)
_stepRow(MusicRiskStep.logout, AppStrings.stepLogout, theme),
if (_stepMessage.isNotEmpty) ...[
const SizedBox(height: 12),
Container(
width: double.infinity,
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: isFailed
? theme.colorScheme.errorContainer
: isDone
? Colors.green.withValues(alpha: 0.1)
: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Text(
_stepMessage,
style: theme.textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
color: isFailed
? theme.colorScheme.onErrorContainer
: theme.colorScheme.onSurface,
),
),
),
],
if (_error != null) ...[
const SizedBox(height: 12),
Container(
width: double.infinity,
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: theme.colorScheme.errorContainer,
borderRadius: BorderRadius.circular(8),
),
child: Text(
_error!,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onErrorContainer,
fontFamily: 'monospace',
),
),
),
],
],
),
),
);
}
Widget _stepRow(MusicRiskStep step, String label, ThemeData theme) {
IconData icon;
Color? color;
if (_step == MusicRiskStep.failed && _step.index <= step.index) {
icon = _step == step ? Icons.error : Icons.circle_outlined;
color = _step == step
? theme.colorScheme.error
: theme.colorScheme.onSurfaceVariant;
} else if (_step.index > step.index) {
icon = Icons.check_circle;
color = Colors.green;
} else if (_step == step) {
icon = Icons.sync;
color = theme.colorScheme.primary;
} else {
icon = Icons.circle_outlined;
color = theme.colorScheme.onSurfaceVariant;
}
return Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: Row(
children: [
Icon(icon, size: 16, color: color),
const SizedBox(width: 10),
Text(
label,
style: theme.textTheme.bodySmall?.copyWith(
color: _step.index >= step.index
? theme.colorScheme.onSurface
: theme.colorScheme.onSurfaceVariant,
fontWeight: _step == step ? FontWeight.w600 : FontWeight.normal,
),
),
],
),
);
}
Widget _buildDropdown({
required int value,
required String label,
required List<DropdownMenuItem<int>> items,
required ValueChanged<int> onChanged,
}) {
return DropdownButtonFormField<int>(
initialValue: value,
decoration: InputDecoration(
labelText: label,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
),
contentPadding: const EdgeInsets.all(14),
),
items: items,
onChanged: _running
? null
: (v) {
if (v == null) return;
onChanged(v);
},
);
}
}
+9 -2
View File
@@ -33,6 +33,10 @@ class _SettingsPageState extends State<SettingsPage> {
@override
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),
+13 -4
View File
@@ -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
+45
View File
@@ -0,0 +1,45 @@
import 'package:flutter/material.dart';
import '../config/strings.dart';
import 'music_risk_feature_view.dart';
/// Port of `eaquira/action/UnlockMusic.py`:
/// 上传 itemKind 5/6/7 道具解锁歌曲与谱面 (UpsertUserAllApi)。
class UnlockMusicPage extends StatelessWidget {
final int userId;
final String? cookies;
final int? loginDateTime;
final int? loginId;
final String? lastLoginDate;
final Future<void> Function()? onLogoutRequested;
const UnlockMusicPage({
super.key,
required this.userId,
this.cookies,
this.loginDateTime,
this.loginId,
this.lastLoginDate,
this.onLogoutRequested,
});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text(AppStrings.unlockFeatureTitle),
),
body: MusicRiskFeatureView(
userId: userId,
cookies: cookies,
loginDateTime: loginDateTime,
loginId: loginId,
lastLoginDate: lastLoginDate,
onLogoutRequested: onLogoutRequested,
mode: MusicRiskFeatureMode.unlockMusic,
featureTitle: AppStrings.unlockFeatureTitle,
featureDesc: AppStrings.unlockFeatureDesc,
),
);
}
}
+3 -76
View File
@@ -10,14 +10,12 @@ class LoginResult {
final int errorId;
final int 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 -1
View File
@@ -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 {
+29 -58
View File
@@ -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.
+381
View File
@@ -0,0 +1,381 @@
import '../config/title_server_config.dart';
import 'title_api_service.dart';
/// Dart port of `eaquira/src/sdgb/payload.py::UserAll_payload` and
/// `eaquira/action/UnlockMusic.py::music_user_all_patcher`.
///
/// Builds the UpsertUserAllApi request packet used by the
/// UpsertMusic (上传成绩) / UnlockMusic (解锁歌曲) workflows.
class UserAllPayloadBuilder {
final TitleServerConfig config;
const UserAllPayloadBuilder(this.config);
static DateTime _shanghaiNow() =>
DateTime.now().toUtc().add(const Duration(hours: 8));
static String _two(int v) => v.toString().padLeft(2, '0');
static String _formatPlayDate(DateTime dt) =>
'${dt.year}-${_two(dt.month)}-${_two(dt.day)}';
static String _formatPlayDateTime(DateTime dt) =>
'${dt.year}-${_two(dt.month)}-${_two(dt.day)} '
'${_two(dt.hour)}:${_two(dt.minute)}:${_two(dt.second)}.0';
static int _toInt(dynamic v) {
if (v is int) return v;
if (v is num) return v.toInt();
return 0;
}
/// "1.56.00" -> 1056000 (Lionheart convertVersionNumber).
static int _convertVersionNumber(String version) {
final parts = version.split('.');
if (parts.length != 3) return 1053000;
final major = int.tryParse(parts[0]);
final minor = int.tryParse(parts[1]);
final patch = int.tryParse(parts[2]);
if (major == null || minor == null || patch == null) return 1053000;
return major * 1000000 + minor * 1000 + patch;
}
/// Build the UpsertUserAllApi packet.
///
/// [generalUserInfo] is the result of `TitleApiService.fetchUserAllData`,
/// keyed by the raw API names.
Map<String, dynamic> build({
required int userId,
required int loginId,
required int loginDateTime,
required Map<String, dynamic> musicData,
required Map<String, Map<String, dynamic>> generalUserInfo,
}) {
final userDataResp = generalUserInfo['GetUserDataApi'] ?? const {};
final userExtendResp = generalUserInfo['GetUserExtendApi'] ?? const {};
final userOptionResp = generalUserInfo['GetUserOptionApi'] ?? const {};
final userRatingResp = generalUserInfo['GetUserRatingApi'] ?? const {};
final userChargeResp = generalUserInfo['GetUserChargeApi'] ?? const {};
final userActivityResp = generalUserInfo['GetUserActivityApi'] ?? const {};
final userMissionResp = generalUserInfo['GetUserMissionDataApi'] ?? const {};
final userData =
userDataResp['userData'] as Map<String, dynamic>? ?? const {};
final playerRating = _toInt(userData['playerRating']);
final charaSlot = (userData['charaSlot'] as List<dynamic>? ?? const [])
.map((e) => _toInt(e))
.toList();
final paddedCharaSlot = List<int>.generate(
5,
(i) => i < charaSlot.length ? charaSlot[i] : 0,
);
final now = _shanghaiNow();
final playDate = _formatPlayDate(now);
final playDateTime = _formatPlayDateTime(now);
final loginDate =
_formatPlayDateTime(DateTime.fromMillisecondsSinceEpoch(loginDateTime * 1000).toUtc().add(const Duration(hours: 8)));
// Lionheart: GetUserDataApi 返回的部分字段不能出现在 UpsertUserAll 的
// userData 中,否则服务器会静默丢弃整包处理。
final newUserData = Map<String, dynamic>.from(userData)
..remove('friendCode')
..remove('nameplateId')
..remove('trophyId')
..remove('cmLastEmoneyBrand')
..remove('cmLastEmoneyCredit');
newUserData
..['accessCode'] = ''
..['isNetMember'] = 1
..['playCount'] = _toInt(userData['playCount']) + 1
..['currentPlayCount'] = _toInt(userData['currentPlayCount']) + 1
..['lastGameId'] = 'SDGB'
..['lastLoginDate'] = loginDate
..['lastPlayDate'] = playDateTime
..['lastPlayCredit'] = 1
..['lastPlayMode'] = 0
..['lastPlaceId'] = config.placeId
..['lastPlaceName'] = config.placeName
..['lastAllNetId'] = 0
..['lastRegionId'] = config.regionId
..['lastRegionName'] = config.regionName
..['lastClientId'] = config.clientId
..['lastCountryCode'] = 'CHN'
..['banState'] = userDataResp['banState']
..['dateTime'] = loginDateTime;
// Lionheart: userOption 需去掉 tempoVolume。
final userOption =
userOptionResp['userOption'] as Map<String, dynamic>? ?? const {};
final newUserOption = Map<String, dynamic>.from(userOption)
..remove('tempoVolume');
// Lionheart: userRating.udemae 的大写重复字段需去掉。
final userRating =
userRatingResp['userRating'] as Map<String, dynamic>? ?? const {};
final udemae = userRating['udemae'] as Map<String, dynamic>?;
if (udemae != null) {
for (final key in [
'MaxLoseNum',
'NpcLoseNum',
'NpcMaxLoseNum',
'NpcMaxWinNum',
'NpcTotalLoseNum',
'NpcTotalWinNum',
'NpcWinNum',
]) {
udemae.remove(key);
}
}
// Lionheart: userChargeList 只保留 4 个字段 (无 extNum1)。
final userChargeList = [
for (final c in (userChargeResp['userChargeList'] as List<dynamic>? ??
const []))
if (c is Map<String, dynamic>)
{
'chargeId': c['chargeId'],
'stock': c['stock'],
'purchaseDate': c['purchaseDate'],
'validDate': c['validDate'],
},
];
final missionList =
userMissionResp['userMissionDataList'] as List<dynamic>? ?? const [];
final missions = <Map<String, dynamic>>[];
for (final raw in missionList.take(6)) {
final m = raw as Map<String, dynamic>;
missions.add({
'type': m['type'],
'difficulty': m['difficulty'],
'targetGenreId': m['targetGenreId'],
'targetGenreTableId': m['targetGenreTableId'],
'conditionGenreId': m['conditionGenreId'],
'conditionGenreTableId': m['conditionGenreTableId'],
'clearFlag': m['clearFlag'],
});
}
final weekly =
userMissionResp['userWeeklyData'] as Map<String, dynamic>? ?? const {};
final achievement = _toInt(musicData['achievement']);
final gradeRating = _toInt(userData['gradeRating']);
final playlog = <String, dynamic>{
'userId': 0,
'orderId': 0,
'playlogId': loginId,
'version': _convertVersionNumber(
userData['lastRomVersion'] as String? ?? '',
),
'placeId': config.placeId,
'placeName': config.placeName,
'loginDate': loginDateTime,
'playDate': playDate,
'userPlayDate': playDateTime,
'type': 0,
'musicId': musicData['musicId'],
'level': musicData['level'],
'trackNo': 1,
'vsMode': 0,
'vsUserName': '',
'vsStatus': 0,
'vsUserRating': 0,
'vsUserAchievement': 0,
'vsUserGradeRank': 0,
'vsRank': 0,
'playerNum': 1,
'playedUserId1': 0,
'playedUserName1': '',
'playedMusicLevel1': 0,
'playedUserId2': 0,
'playedUserName2': '',
'playedMusicLevel2': 0,
'playedUserId3': 0,
'playedUserName3': '',
'playedMusicLevel3': 0,
'achievement': achievement,
'deluxscore': musicData['deluxscoreMax'],
'scoreRank': musicData['scoreRank'],
'maxCombo': 0,
'totalCombo': 1,
'maxSync': 1,
'totalSync': 0,
'tapCriticalPerfect': 0,
'tapPerfect': 0,
'tapGreat': 0,
'tapGood': 0,
'tapMiss': 1,
'holdCriticalPerfect': 0,
'holdPerfect': 0,
'holdGreat': 0,
'holdGood': 0,
'holdMiss': 0,
'slideCriticalPerfect': 0,
'slidePerfect': 0,
'slideGreat': 0,
'slideGood': 0,
'slideMiss': 0,
'touchCriticalPerfect': 0,
'touchPerfect': 0,
'touchGreat': 0,
'touchGood': 0,
'touchMiss': 0,
'breakCriticalPerfect': 0,
'breakPerfect': 0,
'breakGreat': 0,
'breakGood': 0,
'breakMiss': 0,
'isTap': true,
'isHold': false,
'isSlide': false,
'isTouch': false,
'isBreak': false,
'isCriticalDisp': false,
'isFastLateDisp': true,
'fastCount': 0,
'lateCount': 0,
'isAchieveNewRecord': false,
'isDeluxscoreNewRecord': false,
'comboStatus': musicData['comboStatus'],
'syncStatus': musicData['syncStatus'],
'isClear': achievement >= 500000,
'beforeRating': playerRating,
'afterRating': playerRating,
'beforeGrade': gradeRating,
'afterGrade': gradeRating,
'afterGradeRank': _toInt(userData['gradeRank']),
'beforeDeluxRating': playerRating,
'afterDeluxRating': playerRating,
'isPlayTutorial': false,
'isEventMode': false,
'isFreedomMode': false,
'playMode': 0,
'isNewFree': false,
'trialPlayAchievement': -1,
'extNum1': 0,
'extNum2': 0,
'extNum4': 0,
'extBool1': false,
'extBool2': false,
};
for (var i = 0; i < 5; i++) {
playlog['characterId${i + 1}'] = paddedCharaSlot[i];
playlog['characterLevel${i + 1}'] = 1;
playlog['characterAwakening${i + 1}'] = 0;
}
return {
'userId': userId,
'playlogId': loginId,
'isEventMode': false,
'isFreePlay': false,
'loginDateTime': loginDateTime,
'userPlaylogList': [playlog],
'upsertUserAll': {
'userData': [newUserData],
'userExtend': [userExtendResp['userExtend']],
'userOption': [newUserOption],
'userCharacterList': [],
'userGhost': [],
'userMapList': [],
'userLoginBonusList': [],
'userRatingList': [userRating],
'userItemList': [],
'userMusicDetailList': [musicData],
'userCourseList': [],
'userFriendSeasonRankingList': [],
'userChargeList': userChargeList,
'userFavoriteList': [],
'userActivityList': [userActivityResp['userActivity']],
'userMissionDataList': missions,
'userWeeklyData': {
'lastLoginWeek': weekly['lastLoginWeek'],
'beforeLoginWeek': weekly['beforeLoginWeek'],
'friendBonusFlag': weekly['friendBonusFlag'],
},
'userGamePlaylogList': [
{
'playlogId': loginId,
'version': userData['lastRomVersion'],
'playDate': playDateTime,
'playMode': 0,
'useTicketId': -1,
'playCredit': 1,
'playTrack': 1,
'clientId': config.clientId,
'isPlayTutorial': false,
'isEventMode': false,
'isNewFree': false,
'playCount': 0,
'playSpecial': TitleApiService.calcRandom(),
'playOtherUserId': 0,
},
],
'user2pPlaylog': {
'userId1': 0,
'userId2': 0,
'userName1': '',
'userName2': '',
'regionId': 0,
'placeId': 0,
'user2pPlaylogDetailList': [],
},
'userIntimateList': [],
'userShopItemStockList': [],
'userGetPointList': [],
'userTradeItemList': [],
'userFavoritemusicList': [],
'userKaleidxScopeList': [],
'isNewCharacterList': '',
'isNewMapList': '',
'isNewLoginBonusList': '',
'isNewItemList': '',
'isNewMusicDetailList': '0',
'isNewCourseList': '',
'isNewFavoriteList': '',
'isNewFriendSeasonRankingList': '',
'isNewUserIntimateList': '',
'isNewFavoritemusicList': '',
'isNewKaleidxScopeList': '',
},
};
}
/// Generic item patch: replace `userItemList` / `isNewItemList` in the packet.
/// Used by UnlockMusic (itemKind 5/6/7) and 收藏品获取 (itemKind 1/2/3/10/11/12).
void applyItemListPatch(
Map<String, dynamic> packet, {
required List<Map<String, dynamic>> items,
}) {
final upsert = packet['upsertUserAll'] as Map<String, dynamic>;
upsert['userItemList'] = items;
upsert['isNewItemList'] = List.filled(items.length, '1').join();
}
/// Port of `music_user_all_patcher`: append unlock items
/// (itemKind 5=music, 6=master, 7=remaster) to the packet.
void applyMusicUnlockPatch(
Map<String, dynamic> packet, {
required Map<String, dynamic> musicData,
required int musicId,
bool unlockMusic = false,
bool unlockMaster = false,
bool unlockRemaster = false,
}) {
applyItemListPatch(packet, items: [
if (unlockMusic)
{'itemKind': 5, 'itemId': musicId, 'stock': 1, 'isValid': true},
if (unlockMaster)
{'itemKind': 6, 'itemId': musicId, 'stock': 1, 'isValid': true},
if (unlockRemaster)
{'itemKind': 7, 'itemId': musicId, 'stock': 1, 'isValid': true},
]);
final upsert = packet['upsertUserAll'] as Map<String, dynamic>;
upsert['userMusicDetailList'] = [musicData];
upsert['isNewMusicDetailList'] = '1';
}
}