diff --git a/.vscode/launch.json b/.vscode/launch.json index 8e5f7cd..8768a2b 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -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", diff --git a/.vscode/settings.json b/.vscode/settings.json index 80e4e80..0046125 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -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" } diff --git a/README.md b/README.md index 0a3e548..2dbed35 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# ichino +# Purarine A new Flutter project. diff --git a/analysis_options.yaml b/analysis_options.yaml index 0d29021..bf8d421 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -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: diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 3946be0..fe0e0cc 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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 diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 123574f..ba9f0c7 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,7 +1,7 @@ diff --git a/android/app/src/main/kotlin/com/example/ichino/MainActivity.kt b/android/app/src/main/kotlin/dev/naominet/purarine/MainActivity.kt similarity index 94% rename from android/app/src/main/kotlin/com/example/ichino/MainActivity.kt rename to android/app/src/main/kotlin/dev/naominet/purarine/MainActivity.kt index bcb4b97..0d066c3 100644 --- a/android/app/src/main/kotlin/com/example/ichino/MainActivity.kt +++ b/android/app/src/main/kotlin/dev/naominet/purarine/MainActivity.kt @@ -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) diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index ec2ed65..a286986 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -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; diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 64e8cb9..bf7526d 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -7,7 +7,7 @@ CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName - Ichino + Purarine CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier @@ -15,7 +15,7 @@ CFBundleInfoDictionaryVersion 6.0 CFBundleName - ichino + purarine CFBundlePackageType APPL CFBundleShortVersionString diff --git a/ios/Runner/SceneDelegate.swift b/ios/Runner/SceneDelegate.swift index 906855f..a52d4f4 100644 --- a/ios/Runner/SceneDelegate.swift +++ b/ios/Runner/SceneDelegate.swift @@ -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 ) diff --git a/lib/config/strings.dart b/lib/config/strings.dart index 4dd632f..a1c6bae 100644 --- a/lib/config/strings.dart +++ b/lib/config/strings.dart @@ -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 levelLabels = [ - 'Basic (绿)', - 'Advanced (黄)', - 'Expert (红)', - 'Master (紫)', - 'Re:Master (白)', - ]; + static const List collectiblesItemKinds = [1, 2, 3, 10, 11, 12]; - static const List 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 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 = '构建框架'; diff --git a/lib/main.dart b/lib/main.dart index 891de95..5da6d31 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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 { final _qrController = TextEditingController(); - final _apiService = ApiService(); String _qrCode = ''; bool _loading = false; bool _forcePreviewApi = false; @@ -62,7 +61,7 @@ class _LoginPageState extends State { void _onQRContentChanged(String value) { setState(() { - _qrCode = _apiService.extractQRCode(value.trim()); + _qrCode = SessionModel.extractQRCode(value.trim()); }); } @@ -76,7 +75,7 @@ class _LoginPageState extends State { 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 { 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, ), ), diff --git a/lib/models/session_model.dart b/lib/models/session_model.dart new file mode 100644 index 0000000..b4762b9 --- /dev/null +++ b/lib/models/session_model.dart @@ -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 loginWithQr(String qrCodeToken) async { + final result = await _apiService.login(qrCodeToken); + if (result.success) { + _loginResult = result; + notifyListeners(); + } + return result; + } + + Future 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 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(); + } +} diff --git a/lib/models/upsert_user_all.dart b/lib/models/upsert_user_all.dart deleted file mode 100644 index 2cc46bf..0000000 --- a/lib/models/upsert_user_all.dart +++ /dev/null @@ -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 upsertUserAll; - - /// Minimal playlog entry (required — server rejects empty playlogList). - final Map? 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 toJson() { - return { - 'userId': userId, - 'playlogId': playlogId, - 'isEventMode': isEventMode, - 'isFreePlay': isFreePlay, - 'loginDateTime': loginDateTime, - 'userPlaylogList': userPlaylog != null ? [userPlaylog] : [], - 'upsertUserAll': upsertUserAll, - }; - } -} diff --git a/lib/models/user_data.dart b/lib/models/user_data.dart index 4005e2e..7f36e61 100644 --- a/lib/models/user_data.dart +++ b/lib/models/user_data.dart @@ -147,4 +147,4 @@ class UserDataBean { banState: toInt(json['banState']), ); } -} +} \ No newline at end of file diff --git a/lib/models/user_preview.dart b/lib/models/user_preview.dart index 16bc1fc..cb8f10b 100644 --- a/lib/models/user_preview.dart +++ b/lib/models/user_preview.dart @@ -87,4 +87,4 @@ class UserPreviewDataBean { errorId: toInt(json['errorId']), ); } -} +} \ No newline at end of file diff --git a/lib/pages/collectibles_page.dart b/lib/pages/collectibles_page.dart new file mode 100644 index 0000000..ab34177 --- /dev/null +++ b/lib/pages/collectibles_page.dart @@ -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 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, + ), + ); + } +} diff --git a/lib/pages/config_export_import_page.dart b/lib/pages/config_export_import_page.dart index 136166e..6a18ee0 100644 --- a/lib/pages/config_export_import_page.dart +++ b/lib/pages/config_export_import_page.dart @@ -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) { diff --git a/lib/pages/high_risk_feature_page.dart b/lib/pages/high_risk_feature_page.dart new file mode 100644 index 0000000..e91466e --- /dev/null +++ b/lib/pages/high_risk_feature_page.dart @@ -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 Function()? onLogoutRequested; + + const HighRiskFeaturePage({ + super.key, + required this.userId, + this.cookies, + this.loginDateTime, + this.loginId, + this.lastLoginDate, + this.onLogoutRequested, + }); + + @override + State createState() => _HighRiskFeaturePageState(); +} + +class _HighRiskFeaturePageState extends State { + 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, + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index 47f5973..3aeb63a 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -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 { 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 { _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 { 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 { 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 { _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 { } Future _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 _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 { 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 { 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, ); } diff --git a/lib/pages/music_risk_feature_view.dart b/lib/pages/music_risk_feature_view.dart new file mode 100644 index 0000000..57a9af0 --- /dev/null +++ b/lib/pages/music_risk_feature_view.dart @@ -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 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 createState() => _MusicRiskFeatureViewState(); +} + +class _MusicRiskFeatureViewState extends State { + /// 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>? _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 _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 _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 _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> 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 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> items, + required ValueChanged onChanged, + }) { + return DropdownButtonFormField( + 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); + }, + ); + } +} diff --git a/lib/pages/settings_page.dart b/lib/pages/settings_page.dart index 03c7434..c10f3af 100644 --- a/lib/pages/settings_page.dart +++ b/lib/pages/settings_page.dart @@ -33,6 +33,10 @@ class _SettingsPageState extends State { @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 { ), 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), diff --git a/lib/pages/ticket_page.dart b/lib/pages/ticket_page.dart index 729cd46..3e806e8 100644 --- a/lib/pages/ticket_page.dart +++ b/lib/pages/ticket_page.dart @@ -42,16 +42,16 @@ class TicketPage extends StatefulWidget { class _TicketPageState extends State { 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>? _tickets; bool _ticketsLoading = false; @@ -187,6 +187,15 @@ class _TicketPageState extends State { 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!; diff --git a/lib/pages/transfer_package_page.dart b/lib/pages/transfer_package_page.dart deleted file mode 100644 index 0cb06e9..0000000 --- a/lib/pages/transfer_package_page.dart +++ /dev/null @@ -1,1067 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; - -import '../config/responsive.dart'; -import '../config/strings.dart'; -import '../config/title_server_config.dart'; -import '../models/upsert_user_all.dart'; -import '../services/title_api_service.dart'; - -enum TransferStep { idle, fetching, ready, sending, done, failed } - -/// Third-party score upload page (传送分数). -/// -/// Fetches the user's current server state, then uploads a single song score -/// as one valid play session via UpsertUserAllApi. Aligned with the reference -/// Python implementation (UpsertMusic.py / payload.py) and the 1.55.01 dump. -class TransferPackagePage extends StatefulWidget { - final int userId; - final String? cookies; - final int? loginDateTime; - final int? playlogId; - final String? lastLoginDate; - - const TransferPackagePage({ - super.key, - required this.userId, - this.cookies, - this.loginDateTime, - this.playlogId, - this.lastLoginDate, - }); - - @override - State createState() => _TransferPackagePageState(); -} - -class _TransferPackagePageState extends State { - TransferStep _step = TransferStep.idle; - String? _error; - - Map>? _apiData; - - // ── Score form state ── - final _musicIdCtrl = TextEditingController(); - final _achievementCtrl = TextEditingController(text: '1010000'); - final _deluxCtrl = TextEditingController(text: '0'); - final _playCountCtrl = TextEditingController(text: '1'); - int _level = 3; // Master by default - int _comboStatus = 0; - int _syncStatus = 0; - - // ── Cooldown (same as TicketPage: 60s after login) ── - Timer? _cooldownTimer; - int _cooldownRemaining = 0; - - @override - void initState() { - super.initState(); - _syncCooldown(); - _achievementCtrl.addListener(_onAchievementChanged); - } - - @override - void didUpdateWidget(covariant TransferPackagePage oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.loginDateTime != widget.loginDateTime) { - _syncCooldown(); - } - } - - @override - void dispose() { - _cooldownTimer?.cancel(); - _achievementCtrl.removeListener(_onAchievementChanged); - _musicIdCtrl.dispose(); - _achievementCtrl.dispose(); - _deluxCtrl.dispose(); - _playCountCtrl.dispose(); - super.dispose(); - } - - void _onAchievementChanged() => setState(() {}); - - 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; - } - - // ─── Data fetching ─────────────────────────────────────────────────────── - - Future _fetchData() async { - if (!TitleServerConfigHolder().isConfigured) return; - setState(() { - _step = TransferStep.fetching; - _error = null; - _apiData = null; - }); - - try { - final config = TitleServerConfigHolder().config!; - final service = TitleApiService(config, cookies: widget.cookies); - - final data = await service.fetchUserAllData(widget.userId); - - if (!mounted) return; - setState(() { - _apiData = data; - _step = TransferStep.ready; - }); - } on TitleApiException catch (e) { - if (!mounted) return; - setState(() { - _error = e.message; - _step = TransferStep.failed; - }); - } catch (e) { - if (!mounted) return; - setState(() { - _error = e.toString(); - _step = TransferStep.failed; - }); - } - } - - // ─── Score helpers ───────────────────────────────────────────────────── - - /// Parse a rom version string like "1.55.01" into the int form 1055001 - /// (major*1000000 + minor*1000 + patch). Falls back to 1055001. - static int _romVersionToInt(String version) { - final parts = version.split('.'); - if (parts.length < 3) return 1055001; - final major = int.tryParse(parts[0]) ?? 1; - final minor = int.tryParse(parts[1]) ?? 55; - final patch = int.tryParse(parts[2]) ?? 1; - return major * 1000000 + minor * 1000 + patch; - } - - /// Derive scoreRank enum from achievement (verified against the dump: - /// 996696→11, 1004084→12, 1006702→13). - static int _scoreRankFromAchievement(int a) { - if (a >= 1005000) return 13; // SSS+ - if (a >= 1000000) return 12; // SSS - if (a >= 995000) return 11; // SS+ - if (a >= 990000) return 10; // SS - if (a >= 980000) return 9; // S+ - if (a >= 970000) return 8; // S - if (a >= 940000) return 7; // AAA - if (a >= 900000) return 6; // AA - if (a >= 800000) return 5; // A - if (a >= 750000) return 4; // BBB - if (a >= 700000) return 3; // BB - if (a >= 600000) return 2; // B - if (a >= 500000) return 1; // C - return 0; // D - } - - /// Live preview of the rank label for the current achievement input. - String _currentRankLabel() { - final a = int.tryParse(_achievementCtrl.text) ?? 0; - final rank = _scoreRankFromAchievement(a); - const names = { - 13: 'SSS+', 12: 'SSS', 11: 'SS+', 10: 'SS', 9: 'S+', 8: 'S', - 7: 'AAA', 6: 'AA', 5: 'A', 4: 'BBB', 3: 'BB', 2: 'B', 1: 'C', 0: 'D', - }; - final pct = (a / 10000).toStringAsFixed(4); - return '$pct% · ${names[rank]} ($rank)'; - } - - Map get _userDataMap { - final data = _apiData; - if (data == null) return const {}; - final json = data['GetUserDataApi']; - return (json?['userData'] as Map?) ?? const {}; - } - - int get _apiRating => (_userDataMap['playerRating'] as num?)?.toInt() ?? 0; - - String _nowStr() { - final now = DateTime.now(); - String pad(int n) => n.toString().padLeft(2, '0'); - return '${now.year}-${pad(now.month)}-${pad(now.day)} ' - '${pad(now.hour)}:${pad(now.minute)}:${pad(now.second)}.0'; - } - - List _charaSlot() { - final raw = _userDataMap['charaSlot'] as List?; - final slot = raw?.map((e) => (e as num).toInt()).toList() ?? const []; - return List.generate(5, (i) => i < slot.length ? slot[i] : 0); - } - - // ─── Send ────────────────────────────────────────────────────────────── - - Future _sendScore() async { - if (_apiData == null) return; - if (!TitleServerConfigHolder().isConfigured) return; - - final loginDateTime = widget.loginDateTime; - if (loginDateTime == null) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text(AppStrings.transferNotLoggedIn)), - ); - return; - } - - final musicId = int.tryParse(_musicIdCtrl.text); - final achievement = int.tryParse(_achievementCtrl.text); - if (musicId == null || musicId <= 0 || achievement == null) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text(AppStrings.scoreInvalidInput)), - ); - return; - } - - setState(() { - _step = TransferStep.sending; - _error = null; - }); - - try { - final config = TitleServerConfigHolder().config!; - final service = TitleApiService(config, cookies: widget.cookies); - - final payload = _buildScorePayload( - musicId: musicId, - achievement: achievement, - loginDateTime: loginDateTime, - ); - await service.upsertUserAll(payload.toJson(), widget.userId); - - if (!mounted) return; - setState(() => _step = TransferStep.done); - } on TitleApiException catch (e) { - if (!mounted) return; - setState(() { - _error = e.message; - _step = TransferStep.failed; - }); - } catch (e) { - if (!mounted) return; - setState(() { - _error = e.toString(); - _step = TransferStep.failed; - }); - } - } - - // ─── Build UpsertUserAll payload ──────────────────────────────────────── - - UpsertUserAllPayload _buildScorePayload({ - required int musicId, - required int achievement, - required int loginDateTime, - }) { - final data = _apiData!; - final ud = _userDataMap; - final ue = - (data['GetUserExtendApi']?['userExtend'] as Map?) ?? - const {}; - final uo = - (data['GetUserOptionApi']?['userOption'] as Map?) ?? - const {}; - final ur = - (data['GetUserRatingApi']?['userRating'] as Map?) ?? - const {}; - final uc = - (data['GetUserChargeApi']?['userChargeList'] as List?) ?? - const []; - final ua = - (data['GetUserActivityApi']?['userActivity'] as Map?) ?? - const {}; - final um = - (data['GetUserMissionDataApi']?['userMissionDataList'] - as List?) ?? - const []; - final uw = - (data['GetUserMissionDataApi']?['userWeeklyData'] - as Map?) ?? - const {}; - - final cfg = TitleServerConfigHolder().config; - final romVerStr = (ud['lastRomVersion'] as String?) ?? '1.55.01'; - final clientId = (ud['lastClientId'] as String?) ?? cfg?.clientId ?? ''; - final playlogId = widget.playlogId ?? 0; - final nowSec = DateTime.now().millisecondsSinceEpoch ~/ 1000; - final playCount = (ud['playCount'] as num?)?.toInt() ?? 0; - final currentPlayCount = - (ud['currentPlayCount'] as num?)?.toInt() ?? 0; - - final deluxscore = int.tryParse(_deluxCtrl.text) ?? 0; - final musicPlayCount = int.tryParse(_playCountCtrl.text) ?? 1; - final scoreRank = _scoreRankFromAchievement(achievement); - - final musicDetail = _makeMusicDetail( - musicId: musicId, - achievement: achievement, - deluxscoreMax: deluxscore, - scoreRank: scoreRank, - playCount: musicPlayCount, - ); - - final playlog = _makePlaylogEntry( - playlogId: playlogId, - romVerInt: _romVersionToInt(romVerStr), - musicId: musicId, - achievement: achievement, - deluxscore: deluxscore, - scoreRank: scoreRank, - loginDateTime: loginDateTime, - ); - - final upsertUserAll = { - 'userData': [ - { - ...ud, - 'accessCode': '', - 'lastGameId': 'SDGB', - 'lastRomVersion': romVerStr, - 'lastDataVersion': ud['lastDataVersion'] ?? romVerStr, - 'lastLoginDate': widget.lastLoginDate ?? ud['lastLoginDate'] ?? '', - 'lastPlayDate': _nowStr(), - 'lastPlayCredit': 1, - 'lastPlayMode': 0, - 'lastPlaceId': cfg?.placeId ?? ud['lastPlaceId'] ?? 0, - 'lastPlaceName': cfg?.placeName ?? ud['lastPlaceName'] ?? '', - 'lastAllNetId': ud['lastAllNetId'] ?? 0, - 'lastRegionId': cfg?.regionId ?? ud['lastRegionId'] ?? 0, - 'lastRegionName': cfg?.regionName ?? ud['lastRegionName'] ?? '', - 'lastClientId': clientId, - 'lastCountryCode': ud['lastCountryCode'] ?? 'CHN', - 'playCount': playCount + 1, - 'currentPlayCount': currentPlayCount + 1, - 'banState': data['GetUserDataApi']?['banState'] ?? ud['banState'] ?? 0, - 'dateTime': nowSec, - }, - ], - 'userExtend': [ue], - 'userOption': [uo], - 'userCharacterList': [], - 'userGhost': [], - 'userMapList': [], - 'userLoginBonusList': [], - 'userRatingList': [ur], - 'userItemList': [], - 'userMusicDetailList': [musicDetail], - 'userCourseList': [], - 'userFriendSeasonRankingList': [], - 'userChargeList': uc, - 'userFavoriteList': [ - {'itemKind': 3, 'itemIdList': []}, - {'itemKind': 1, 'itemIdList': []}, - {'itemKind': 2, 'itemIdList': []}, - {'itemKind': 10, 'itemIdList': []}, - {'itemKind': 11, 'itemIdList': []}, - ], - 'userActivityList': [ua], - 'userMissionDataList': um, - 'userWeeklyData': uw, - 'userGamePlaylogList': [ - { - 'playlogId': playlogId, - 'version': romVerStr, - 'playDate': _nowStr(), - 'playMode': 0, - 'useTicketId': -1, - 'playCredit': 1, - 'playTrack': 1, - 'clientId': 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': '11111', - 'isNewFriendSeasonRankingList': '', - 'isNewUserIntimateList': '', - 'isNewFavoritemusicList': '', - 'isNewKaleidxScopeList': '', - }; - - return UpsertUserAllPayload( - userId: widget.userId, - playlogId: playlogId, - loginDateTime: loginDateTime, - upsertUserAll: upsertUserAll, - userPlaylog: playlog, - ); - } - - Map _makeMusicDetail({ - required int musicId, - required int achievement, - required int deluxscoreMax, - required int scoreRank, - required int playCount, - }) { - return { - 'musicId': musicId, - 'level': _level, - 'playCount': playCount, - 'achievement': achievement, - 'comboStatus': _comboStatus, - 'syncStatus': _syncStatus, - 'deluxscoreMax': deluxscoreMax, - 'scoreRank': scoreRank, - 'extNum1': 0, - }; - } - - /// Build the userPlaylogList entry. Judgment detail uses the placeholder - /// values from the reference Python script (payload.py). Character levels - /// are 1/0 since this page no longer edits characters. - Map _makePlaylogEntry({ - required int playlogId, - required int romVerInt, - required int musicId, - required int achievement, - required int deluxscore, - required int scoreRank, - required int loginDateTime, - }) { - final rating = _apiRating; - final cfg = TitleServerConfigHolder().config; - final slot = _charaSlot(); - - return { - 'userId': 0, - 'orderId': 0, - 'playlogId': playlogId, - 'version': romVerInt, - 'placeId': cfg?.placeId ?? 0, - 'placeName': cfg?.placeName ?? '', - 'loginDate': loginDateTime, - 'playDate': _nowStr().split(' ')[0], - 'userPlayDate': _nowStr(), - 'type': 0, - 'musicId': musicId, - 'level': _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, - 'characterId1': slot[0], - 'characterLevel1': 1, - 'characterAwakening1': 0, - 'characterId2': slot[1], - 'characterLevel2': 1, - 'characterAwakening2': 0, - 'characterId3': slot[2], - 'characterLevel3': 1, - 'characterAwakening3': 0, - 'characterId4': slot[3], - 'characterLevel4': 1, - 'characterAwakening4': 0, - 'characterId5': slot[4], - 'characterLevel5': 1, - 'characterAwakening5': 0, - 'achievement': achievement, - 'deluxscore': deluxscore, - 'scoreRank': scoreRank, - 'maxCombo': 0, - 'totalCombo': 128, - 'maxSync': 0, - 'totalSync': 0, - 'tapCriticalPerfect': 101, - 'tapPerfect': 0, - 'tapGreat': 0, - 'tapGood': 0, - 'tapMiss': 0, - 'holdCriticalPerfect': 9, - 'holdPerfect': 0, - 'holdGreat': 0, - 'holdGood': 0, - 'holdMiss': 0, - 'slideCriticalPerfect': 4, - 'slidePerfect': 0, - 'slideGreat': 0, - 'slideGood': 0, - 'slideMiss': 0, - 'touchCriticalPerfect': 0, - 'touchPerfect': 0, - 'touchGreat': 0, - 'touchGood': 0, - 'touchMiss': 0, - 'breakCriticalPerfect': 1, - 'breakPerfect': 0, - 'breakGreat': 0, - 'breakGood': 0, - 'breakMiss': 0, - 'isTap': true, - 'isHold': true, - 'isSlide': true, - 'isTouch': false, - 'isBreak': true, - 'isCriticalDisp': true, - 'isFastLateDisp': true, - 'fastCount': 0, - 'lateCount': 0, - 'isAchieveNewRecord': false, - 'isDeluxscoreNewRecord': false, - 'comboStatus': _comboStatus, - 'syncStatus': _syncStatus, - 'isClear': true, - 'beforeRating': rating, - 'afterRating': rating, - 'beforeGrade': 0, - 'afterGrade': 0, - 'afterGradeRank': 0, - 'beforeDeluxRating': rating, - 'afterDeluxRating': rating, - 'isPlayTutorial': false, - 'isEventMode': false, - 'isFreedomMode': false, - 'playMode': 0, - 'isNewFree': false, - 'trialPlayAchievement': -1, - 'extNum1': 0, - 'extNum2': 0, - 'extNum4': 101, - 'extBool1': false, - 'extBool2': false, - }; - } - - // ─── UI ───────────────────────────────────────────────────────────────── - - @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.titleServerNotConfigured, - style: theme.textTheme.titleMedium), - ], - ), - ), - ); - } - - return Column( - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), - child: ConstrainedBox( - constraints: BoxConstraints(maxWidth: responsiveMaxWidth(context)), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (widget.loginDateTime == null) - _buildBanner( - theme, - Icons.warning_amber_rounded, - AppStrings.transferNotLoggedIn, - theme.colorScheme.errorContainer, - theme.colorScheme.onErrorContainer, - ), - if (widget.loginDateTime != null && _cooldownRemaining > 0) - _buildCooldownBanner(theme), - _buildDescCard(theme), - const SizedBox(height: 12), - _buildFetchButton(theme), - ], - ), - ), - ), - if (_apiData != null && _step != TransferStep.fetching) - Expanded( - child: SingleChildScrollView( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), - child: ConstrainedBox( - constraints: - BoxConstraints(maxWidth: responsiveMaxWidth(context)), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildDataReadyBanner(theme), - const SizedBox(height: 12), - _buildScoreForm(theme), - const SizedBox(height: 20), - _buildSendButton(theme), - if (_error != null) ...[ - const SizedBox(height: 12), - _buildErrorCard(theme), - ], - if (_step == TransferStep.done) ...[ - const SizedBox(height: 12), - _buildDoneCard(theme), - ], - ], - ), - ), - ), - ), - if (_step == TransferStep.fetching) _buildProgress(theme), - if (_error != null && _apiData == null) ...[ - const SizedBox(height: 12), - _buildErrorCard(theme), - ], - ], - ); - } - - Widget _buildDescCard(ThemeData theme) { - return Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), - decoration: BoxDecoration( - color: theme.colorScheme.tertiaryContainer.withValues(alpha: 0.4), - borderRadius: BorderRadius.circular(10), - ), - child: Text( - AppStrings.transferDesc, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onTertiaryContainer, - ), - ), - ); - } - - Widget _buildDataReadyBanner(ThemeData theme) { - return Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), - decoration: BoxDecoration( - color: Colors.green.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(10), - ), - child: Row( - children: [ - const Icon(Icons.check_circle_outline, size: 18, color: Colors.green), - const SizedBox(width: 8), - Expanded( - child: Text( - AppStrings.transferDataReady, - style: theme.textTheme.bodySmall?.copyWith(color: Colors.green), - ), - ), - ], - ), - ); - } - - 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.packetCooldownNotice(_cooldownRemaining), - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onTertiaryContainer, - ), - ), - ), - ], - ), - ); - } - - Widget _buildBanner( - ThemeData theme, - IconData icon, - String text, - Color bgColor, - Color fgColor, - ) { - return Container( - width: double.infinity, - margin: const EdgeInsets.only(bottom: 12), - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), - decoration: BoxDecoration( - color: bgColor, - borderRadius: BorderRadius.circular(10), - ), - child: Row( - children: [ - Icon(icon, size: 18, color: fgColor), - const SizedBox(width: 8), - Expanded( - child: Text( - text, - style: theme.textTheme.bodySmall?.copyWith(color: fgColor), - ), - ), - ], - ), - ); - } - - Widget _buildFetchButton(ThemeData theme) { - final hasLogin = widget.loginDateTime != null; - final isLoading = _step == TransferStep.fetching; - final isSending = _step == TransferStep.sending; - final canFetch = hasLogin && !isLoading && !isSending; - final hasData = _apiData != null; - - return SizedBox( - width: double.infinity, - child: FilledButton.icon( - onPressed: canFetch ? _fetchData : null, - icon: isLoading - ? const SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator( - strokeWidth: 2, color: Colors.white), - ) - : Icon(hasData ? Icons.refresh : Icons.download, size: 20), - label: Text(isLoading - ? AppStrings.transferFetching - : (hasData - ? AppStrings.transferRefetch - : AppStrings.transferFetchData)), - style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 14), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - ), - ); - } - - Widget _buildScoreForm(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: [ - Text( - AppStrings.scoreFormTitle, - style: theme.textTheme.labelLarge?.copyWith( - color: theme.colorScheme.primary, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 16), - _numberField( - theme, - controller: _musicIdCtrl, - label: AppStrings.scoreMusicId, - ), - const SizedBox(height: 14), - _dropdownField( - theme, - label: AppStrings.scoreLevel, - value: _level, - labels: AppStrings.levelLabels, - onChanged: (v) => setState(() => _level = v), - ), - const SizedBox(height: 14), - _numberField( - theme, - controller: _achievementCtrl, - label: AppStrings.scoreAchievement, - helperText: AppStrings.scoreAchievementHint, - ), - const SizedBox(height: 6), - _rankPreview(theme), - const SizedBox(height: 14), - _dropdownField( - theme, - label: AppStrings.scoreComboStatus, - value: _comboStatus, - labels: AppStrings.comboStatusLabels, - onChanged: (v) => setState(() => _comboStatus = v), - ), - const SizedBox(height: 14), - _dropdownField( - theme, - label: AppStrings.scoreSyncStatus, - value: _syncStatus, - labels: AppStrings.syncStatusLabels, - onChanged: (v) => setState(() => _syncStatus = v), - ), - const SizedBox(height: 14), - _numberField( - theme, - controller: _deluxCtrl, - label: AppStrings.scoreDeluxscore, - ), - const SizedBox(height: 14), - _numberField( - theme, - controller: _playCountCtrl, - label: AppStrings.scorePlayCount, - ), - ], - ), - ), - ); - } - - Widget _numberField( - ThemeData theme, { - required TextEditingController controller, - required String label, - String? helperText, - }) { - return TextField( - controller: controller, - keyboardType: TextInputType.number, - inputFormatters: [FilteringTextInputFormatter.digitsOnly], - decoration: InputDecoration( - labelText: label, - helperText: helperText, - isDense: true, - contentPadding: - const EdgeInsets.symmetric(horizontal: 12, vertical: 14), - border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), - ), - ); - } - - Widget _dropdownField( - ThemeData theme, { - required String label, - required int value, - required List labels, - required ValueChanged onChanged, - }) { - return DropdownButtonFormField( - initialValue: value, - isExpanded: true, - decoration: InputDecoration( - labelText: label, - isDense: true, - contentPadding: - const EdgeInsets.symmetric(horizontal: 12, vertical: 12), - border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), - ), - items: [ - for (var i = 0; i < labels.length; i++) - DropdownMenuItem(value: i, child: Text(labels[i])), - ], - onChanged: (v) { - if (v != null) onChanged(v); - }, - ); - } - - Widget _rankPreview(ThemeData theme) { - return Row( - children: [ - Icon(Icons.military_tech_outlined, - size: 16, color: theme.colorScheme.primary), - const SizedBox(width: 6), - Text( - '${AppStrings.scoreRankLabel}: ', - style: theme.textTheme.labelSmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - Text( - _currentRankLabel(), - style: theme.textTheme.labelMedium?.copyWith( - color: theme.colorScheme.primary, - fontWeight: FontWeight.w600, - ), - ), - ], - ); - } - - Widget _buildSendButton(ThemeData theme) { - final isSending = _step == TransferStep.sending; - final hasLogin = widget.loginDateTime != null; - final onCooldown = _cooldownRemaining > 0; - final canSend = hasLogin && !isSending && !onCooldown; - - return SizedBox( - width: double.infinity, - child: FilledButton.icon( - onPressed: canSend ? _sendScore : null, - icon: isSending - ? const SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator( - strokeWidth: 2, color: Colors.white), - ) - : const Icon(Icons.file_upload_outlined, size: 20), - label: Text(isSending - ? AppStrings.transferSending - : AppStrings.transferSend), - style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 14), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - ), - ); - } - - Widget _buildProgress(ThemeData theme) { - final text = _step == TransferStep.fetching - ? AppStrings.transferFetching - : AppStrings.transferSending; - return Padding( - padding: const EdgeInsets.symmetric(vertical: 16), - child: Center( - child: Column( - children: [ - const CircularProgressIndicator(), - const SizedBox(height: 12), - Text( - text, - style: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - ); - } - - Widget _buildErrorCard(ThemeData theme) { - return Container( - width: double.infinity, - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: theme.colorScheme.errorContainer, - borderRadius: BorderRadius.circular(10), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon(Icons.error, - size: 16, color: theme.colorScheme.onErrorContainer), - const SizedBox(width: 8), - Text( - AppStrings.transferFailed, - style: theme.textTheme.labelMedium?.copyWith( - color: theme.colorScheme.onErrorContainer, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - const SizedBox(height: 6), - Text( - _error!, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onErrorContainer, - fontFamily: 'monospace', - ), - ), - ], - ), - ); - } - - Widget _buildDoneCard(ThemeData theme) { - return Container( - width: double.infinity, - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.green.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(10), - ), - child: Row( - children: [ - const Icon(Icons.check_circle, size: 18, color: Colors.green), - const SizedBox(width: 8), - Text( - AppStrings.transferSuccess, - style: theme.textTheme.labelMedium?.copyWith( - color: Colors.green, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ); - } -} diff --git a/lib/pages/unlock_music_page.dart b/lib/pages/unlock_music_page.dart new file mode 100644 index 0000000..5336cc0 --- /dev/null +++ b/lib/pages/unlock_music_page.dart @@ -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 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, + ), + ); + } +} diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index 7fe6431..1fc0c57 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -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 = []; - // 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, ); } } diff --git a/lib/services/qr_service_native.dart b/lib/services/qr_service_native.dart index f75ab41..cabdecf 100644 --- a/lib/services/qr_service_native.dart +++ b/lib/services/qr_service_native.dart @@ -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 decodeQRFromBytes(Uint8List bytes) async { try { diff --git a/lib/services/title_api_service.dart b/lib/services/title_api_service.dart index f696326..588e6ab 100644 --- a/lib/services/title_api_service.dart +++ b/lib/services/title_api_service.dart @@ -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 = []; - 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 = { - '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 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 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 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. diff --git a/lib/services/user_all_payload_builder.dart b/lib/services/user_all_payload_builder.dart new file mode 100644 index 0000000..e673cc7 --- /dev/null +++ b/lib/services/user_all_payload_builder.dart @@ -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 build({ + required int userId, + required int loginId, + required int loginDateTime, + required Map musicData, + required Map> 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? ?? const {}; + final playerRating = _toInt(userData['playerRating']); + + final charaSlot = (userData['charaSlot'] as List? ?? const []) + .map((e) => _toInt(e)) + .toList(); + final paddedCharaSlot = List.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.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? ?? const {}; + final newUserOption = Map.from(userOption) + ..remove('tempoVolume'); + + // Lionheart: userRating.udemae 的大写重复字段需去掉。 + final userRating = + userRatingResp['userRating'] as Map? ?? const {}; + final udemae = userRating['udemae'] as Map?; + 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? ?? + const [])) + if (c is Map) + { + 'chargeId': c['chargeId'], + 'stock': c['stock'], + 'purchaseDate': c['purchaseDate'], + 'validDate': c['validDate'], + }, + ]; + + final missionList = + userMissionResp['userMissionDataList'] as List? ?? const []; + final missions = >[]; + for (final raw in missionList.take(6)) { + final m = raw as Map; + 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? ?? const {}; + + final achievement = _toInt(musicData['achievement']); + final gradeRating = _toInt(userData['gradeRating']); + final playlog = { + '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 packet, { + required List> items, + }) { + final upsert = packet['upsertUserAll'] as Map; + 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 packet, { + required Map 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; + upsert['userMusicDetailList'] = [musicData]; + upsert['isNewMusicDetailList'] = '1'; + } +} diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt index d0f37bb..ee5144e 100644 --- a/linux/CMakeLists.txt +++ b/linux/CMakeLists.txt @@ -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. diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index 217fa2c..9994dd3 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -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); diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index adff075..070ae0d 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -65,7 +65,7 @@ 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; - 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 = ""; }; 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; @@ -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; }; diff --git a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 51ff3ba..d4994bb 100644 --- a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -15,7 +15,7 @@ @@ -33,7 +33,7 @@ @@ -49,7 +49,7 @@ @@ -84,7 +84,7 @@ @@ -101,7 +101,7 @@ diff --git a/macos/Runner/Configs/AppInfo.xcconfig b/macos/Runner/Configs/AppInfo.xcconfig index a46b0ae..1718b2b 100644 --- a/macos/Runner/Configs/AppInfo.xcconfig +++ b/macos/Runner/Configs/AppInfo.xcconfig @@ -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. diff --git a/pubspec.lock b/pubspec.lock index e4b7185..33f6b61 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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: diff --git a/pubspec.yaml b/pubspec.yaml index d0d5c2d..322a51a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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. diff --git a/test/widget_test.dart b/test/widget_test.dart index 77d6a47..394bfa0 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -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); }); } diff --git a/web/index.html b/web/index.html index c37e731..70b58c3 100644 --- a/web/index.html +++ b/web/index.html @@ -5,14 +5,14 @@ - + - + - Ichino + Purarine diff --git a/web/manifest.json b/web/manifest.json index 987a302..e9dc35f 100644 --- a/web/manifest.json +++ b/web/manifest.json @@ -1,6 +1,6 @@ { - "name": "ichino", - "short_name": "ichino", + "name": "Purarine", + "short_name": "Purarine", "start_url": ".", "display": "standalone", "background_color": "#0175C2", diff --git a/web_entrypoint.dart b/web_entrypoint.dart deleted file mode 100644 index e69de29..0000000 diff --git a/windows/CMakeLists.txt b/windows/CMakeLists.txt index 889d71b..c1ea500 100644 --- a/windows/CMakeLists.txt +++ b/windows/CMakeLists.txt @@ -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. diff --git a/windows/runner/Runner.rc b/windows/runner/Runner.rc index 8622e46..cbc94d6 100644 --- a/windows/runner/Runner.rc +++ b/windows/runner/Runner.rc @@ -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 diff --git a/windows/runner/main.cpp b/windows/runner/main.cpp index 78fb208..8e6d582 100644 --- a/windows/runner/main.cpp +++ b/windows/runner/main.cpp @@ -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);