refactor: 拆分主页与公共组件,扫码登录改走 WinHTTP

- 抽出 lib/core 的日期/JSON 工具,收敛各页重复解析
- 主页与音乐风险页拆成组件,风险功能共用一套运行框架
- 修复设置页作为主页页签保存时弹掉根路由导致的黑屏
- 登录对齐 Astral aimedb 协议:84 位二维码校验、签名与请求头修正,
  补 GlobalSign 中间证书;Windows 改用 WinHTTP 绕过服务端对
  BoringSSL 的 TLS 指纹拦截(证书校验保持开启)
- 补开发预览数据与测试
This commit is contained in:
Bad0RANG3 committed 2026-10-25 15:51:10 +08:00
1 parent 0cb5320290
commit e9357f5969
55 files changed
+4387 -3392

No files matched your search

+5 -7
View File
@@ -12,7 +12,7 @@ class AppStrings {
// Login
static const appTitle = 'Ichino';
static const qrCodeToken = 'QR Code 令牌';
static const qrHint = '粘贴二维码解析内容... / 粘贴连接信息';
static const qrHint = '粘贴二维码解析内容或二维码链接... / 粘贴连接信息';
static const uploadQR = '上传二维码';
static const login = '登录';
static const loggingIn = '登录中...';
@@ -21,7 +21,7 @@ class AppStrings {
static const qrScanError = '解析失败';
static const forcePreviewApi = '强制使用 Preview API';
static const loginFailed = '登录失败';
static const qrTokenUnrecognized = '没认出 QR 令牌(内容里应当含 SGWCMAID),也可以粘贴「连接信息」。';
static const qrTokenUnrecognized = '没认出 QR 令牌';
static const requestFailed = '请求失败';
static const settingsTooltip = '设置';
@@ -164,6 +164,7 @@ class AppStrings {
static const authServerSettings = 'Auth Server';
static const machineSettings = '机器信息';
static const save = '保存';
static const configSaved = '配置已保存';
static const labelTitleServerUrl = 'Title Server URL';
static const hintTitleServerUrl = '';
static const labelAesKey = 'AES Key';
@@ -185,11 +186,8 @@ class AppStrings {
static const labelPlaceName = 'Place 名称';
static const hintPlaceName = 'Place name';
static const labelKeychipId = 'Keychip ID';
static const hintKeychipId = 'ID';
static const labelAimeUrl = 'Aime URL';
static const hintAimeUrl = '';
static const labelAimeSalt = 'Aime Salt';
static const hintAimeSalt = 'API Key 派生用 Salt';
static const helpKeychipId =
'机台完整 Keychip(如 A63E-01C30471007);只填 11 位短值时按 Place ID 补全';
static String fieldRequired(String label) => '$label 不能为空';
// Config Export/Import
+11 -13
View File
@@ -14,9 +14,6 @@ class TitleServerConfig {
final String regionName;
final String placeName;
final String keychipId;
final String aimeUrl;
final String aimeSalt;
final String openGameID;
const TitleServerConfig({
required this.titleServerUrl,
@@ -30,14 +27,21 @@ class TitleServerConfig {
this.regionName = '',
this.placeName = '',
this.keychipId = '',
this.aimeUrl = '',
this.aimeSalt = '',
String? openGameID,
}) : openGameID = openGameID ?? 'MAID';
});
List<int> get aesKeyBytes => utf8.encode(aesKey);
List<int> get aesIvBytes => utf8.encode(aesIv);
/// 扫码登录签的是**完整 Keychip**(`A63E-01C30471007`),游戏服务器那边才用
/// 11 位短值。这里按 Astral 的约定补出完整形式:前 4 位 + 连字符 + 余下 + placeId,
/// 所以只填了短值也能还原;填不满 11 位就当没配。
String get fullKeychipId {
final raw = keychipId.replaceAll(RegExp(r'[\s\-]'), '').toUpperCase();
if (raw.length < 11) return '';
final short = '${raw.substring(0, 4)}-${raw.substring(4)}';
return raw.length == 11 ? '$short$placeId' : short;
}
Map<String, dynamic> toJson() => {
'titleServerUrl': titleServerUrl,
'aesKey': aesKey,
@@ -50,9 +54,6 @@ class TitleServerConfig {
'regionName': regionName,
'placeName': placeName,
'keychipId': keychipId,
'aimeUrl': aimeUrl,
'aimeSalt': aimeSalt,
'openGameID': openGameID,
};
factory TitleServerConfig.fromJson(Map<String, dynamic> json) {
@@ -68,9 +69,6 @@ class TitleServerConfig {
regionName: json['regionName'] as String? ?? '',
placeName: json['placeName'] as String? ?? '',
keychipId: json['keychipId'] as String? ?? '',
aimeUrl: json['aimeUrl'] as String? ?? '',
aimeSalt: json['aimeSalt'] as String? ?? '',
openGameID: json['openGameID'] as String? ?? 'MAID',
);
}
}
+22
View File
@@ -0,0 +1,22 @@
/// 机台与 title server 都按 UTC+8 记录日期,客户端格式见
/// `TimeManager.GetDateString` / `GetNowDateString`。
library;
const Duration shanghaiOffset = Duration(hours: 8);
DateTime shanghaiNow() => DateTime.now().toUtc().add(shanghaiOffset);
DateTime shanghaiFromUnixSeconds(int seconds) =>
DateTime.fromMillisecondsSinceEpoch(seconds * 1000, isUtc: true)
.add(shanghaiOffset);
String _two(int value) => value.toString().padLeft(2, '0');
String formatDate(DateTime dt) =>
'${dt.year}-${_two(dt.month)}-${_two(dt.day)}';
String formatDateTime(DateTime dt) =>
'${formatDate(dt)} ${_two(dt.hour)}:${_two(dt.minute)}:${_two(dt.second)}';
/// `ToString("yyyy-MM-dd HH:mm:ss.f")`:秒后固定带一位小数。
String formatPlayDateTime(DateTime dt) => '${formatDateTime(dt)}.0';
+38
View File
@@ -0,0 +1,38 @@
/// 服务器返回的 JSON 数值并不总是稳定的 Dart 类型:同一个字段可能是
/// `int`、`double`,也可能是字符串。这些函数把所有宽松转换收口到一处。
library;
int jsonInt(dynamic value, {int fallback = 0}) {
if (value is int) return value;
if (value is num) return value.toInt();
if (value is String) return int.tryParse(value) ?? fallback;
return fallback;
}
double jsonDouble(dynamic value, {double fallback = 0.0}) {
if (value is double) return value;
if (value is int) return value.toDouble();
if (value is num) return value.toDouble();
if (value is String) return double.tryParse(value) ?? fallback;
return fallback;
}
bool jsonBool(dynamic value, {bool fallback = false}) {
if (value is bool) return value;
if (value is num) return value != 0;
if (value is String) {
final normalized = value.trim().toLowerCase();
return normalized == '1' || normalized == 'true';
}
return fallback;
}
String jsonString(dynamic value, {String fallback = ''}) =>
value?.toString() ?? fallback;
Map<String, dynamic> jsonMap(dynamic value) =>
value is Map<String, dynamic> ? value : const {};
List<Map<String, dynamic>> jsonMapList(dynamic value) => value is List
? value.whereType<Map<String, dynamic>>().toList(growable: false)
: const [];
+114
View File
@@ -0,0 +1,114 @@
import '../models/session_model.dart';
import '../models/user_data.dart';
import '../models/user_preview.dart';
import '../services/title_api_service.dart';
/// 开发期 UI 预览用的假数据,不连 aimedb / title server。
///
/// 改这里就能调主页各卡片展示的数值;头像 `iconId: 0` 显示占位图标,
/// 改成非 0 会去 assets2.lxns.net 拉真实头像。
const int uiPreviewUserId = 12345678901234;
const String uiPreviewToken = 'ui-preview-token';
const int uiPreviewLoginId = 987654;
const UserDataBean uiPreviewUserData = UserDataBean(
userId: uiPreviewUserId,
userName: 'Ichino',
friendCode: '123456789012345',
isNetMember: true,
point: 234567,
totalPoint: 9876543,
playerRating: 15234,
playerOldRating: 8200,
playerNewRating: 7034,
highestRating: 15456,
musicRating: 13800,
gradeRank: 12,
courseRank: 34,
classRank: 56,
nameplateId: 101,
frameId: 202,
iconId: 0,
trophyId: 303,
plateId: 404,
titleId: 505,
partnerId: 606,
playCount: 2345,
currentPlayCount: 123,
totalSync: 45678,
totalAchievement: 123456,
totalDeluxscore: 98765,
totalAwake: 5678,
lastGameId: 'SDEZ',
lastRomVersion: '1.60.00',
lastDataVersion: '1.60.00',
lastLoginDate: '2026-10-25 12:34:56',
lastPlayDate: '2026-10-25 12:00:00',
firstGameId: 'SDEZ',
firstRomVersion: '1.00.00',
firstDataVersion: '1.00.00',
firstPlayDate: '2021-07-01 10:00:00',
lastRegionName: '上海',
lastRegionId: 1007,
dailyBonusDate: '2026-10-25 12:00:00',
banState: 0,
);
const UserPreviewDataBean uiPreviewPreview = UserPreviewDataBean(
userId: uiPreviewUserId,
userName: 'Ichino',
isLogin: true,
loginId: uiPreviewLoginId,
lastGameId: 'SDEZ',
lastRomVersion: '1.60.00',
lastDataVersion: '1.60.00',
lastLoginDate: '2026-10-25 12:34:56',
lastPlayDate: '2026-10-25 12:00:00',
playerRating: 15234,
nameplateId: 101,
iconId: 0,
trophyId: 303,
isNetMember: true,
isInherit: false,
totalAwake: 5678,
dispRate: 1,
dailyBonusDate: '2026-10-25 12:00:00',
headPhoneVolume: 80,
banState: 0,
errorId: 0,
);
const String uiPreviewRawResponse = '''
{
"userId": $uiPreviewUserId,
"userName": "Ichino",
"isLogin": true,
"playerRating": 15234,
"banState": 0,
"errorId": 0
}''';
/// 预置登录态与主页种子数据;风险/票据页会按「已登录」渲染。
SessionRestoreResult createUiPreviewSession() {
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
SessionModel.instance
..setGameLogin(
UserLoginResult(
token: uiPreviewToken,
loginId: uiPreviewLoginId,
lastLoginDate: uiPreviewUserData.lastLoginDate,
// 提前两分钟,避免页面一进来就卡在 60 秒登录冷却里。
loginDateTime: now - 120,
),
)
..updateCookies('JSESSIONID=UI-PREVIEW');
return const SessionRestoreResult(
reusedCookie: true,
userData: uiPreviewUserData,
preview: uiPreviewPreview,
rawPreviewResponse: uiPreviewRawResponse,
loggedIn: true,
);
}
+22
View File
@@ -0,0 +1,22 @@
import 'package:flutter/material.dart';
import '../main.dart';
import '../pages/home_page.dart';
import 'ui_preview_data.dart';
/// 开发期 UI 预览入口:跳过二维码登录,直接用假数据进主页。
///
/// flutter run -d windows -t lib/dev/ui_preview_main.dart --no-pub
void main() {
WidgetsFlutterBinding.ensureInitialized();
final restored = createUiPreviewSession();
runApp(
MyApp(
home: HomePage(
userId: uiPreviewUserId,
token: uiPreviewToken,
restored: restored,
),
),
);
}
+5 -2
View File
@@ -11,7 +11,10 @@ void main() async {
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
/// 默认从登录页启动;开发预览等入口可传入别的根页面。
final Widget? home;
const MyApp({super.key, this.home});
@override
Widget build(BuildContext context) {
@@ -31,7 +34,7 @@ class MyApp extends StatelessWidget {
brightness: Brightness.dark,
),
),
home: const LoginPage(),
home: home ?? const LoginPage(),
);
}
}
+33 -9
View File
@@ -17,6 +17,9 @@ class SessionRestoreResult {
final UserDataBean? userData;
final UserPreviewDataBean? preview;
/// 恢复过程中 GetUserPreviewApi 的响应原文,供主页调试卡展示。
final String? rawPreviewResponse;
/// 服务器认为这次会话仍在登录中(`preview.isLogin`),或刚用令牌登录成功。
/// 只有为 `true` 时票据与风险页才开放写入,并且照常吃 60 秒登录后冷却。
final bool loggedIn;
@@ -25,6 +28,7 @@ class SessionRestoreResult {
required this.reusedCookie,
this.userData,
this.preview,
this.rawPreviewResponse,
this.loggedIn = false,
});
}
@@ -44,11 +48,22 @@ class SessionModel extends ChangeNotifier {
UserLoginResult? get gameLogin => _gameLogin;
String? get cookies => _cookies;
static String extractQRCode(String qrCodeToken) =>
ApiService.extractQRCode(qrCodeToken);
/// 构造会回写 Cookie 的服务。
///
/// 生产环境优先用会话里最新的 JSESSIONID:Title Server 可能在任意响应里
/// 轮换 Cookie,页面若一直拿着进入时的快照,后续请求就会带着旧值。
/// 直接构造页面的测试没有会话,才退回调用方传入的 [cookies]。
TitleApiService? createTitleService({String? cookies}) {
return TitleApiService.fromHolder(
cookies: _cookies ?? cookies,
onCookiesChanged: updateCookies,
);
}
Future<LoginResult> loginWithQr(String qrCodeToken) async {
final result = await _apiService.login(qrCodeToken);
/// [qrInput] 可以是 84 位二维码串,也可以是主页导出的二维码链接;
/// 格式不合预期时 [ApiService.login] 抛 [FormatException]。
Future<LoginResult> loginWithQr(String qrInput) async {
final result = await _apiService.login(qrInput);
if (result.success) {
_loginResult = result;
notifyListeners();
@@ -79,7 +94,11 @@ class SessionModel extends ChangeNotifier {
final config = TitleServerConfigHolder().config;
if (config == null) return;
final service = TitleApiService(config, cookies: cookies ?? _cookies);
final service = TitleApiService(
config,
cookies: cookies ?? _cookies,
onCookiesChanged: updateCookies,
);
try {
await service.userLogout(
userId: userId,
@@ -136,7 +155,10 @@ class SessionModel extends ChangeNotifier {
SessionShare share,
String cookie,
) async {
final service = TitleApiService.fromHolder(cookies: cookie);
final service = TitleApiService.fromHolder(
cookies: cookie,
onCookiesChanged: updateCookies,
);
if (service == null) return null;
try {
@@ -159,6 +181,7 @@ class SessionModel extends ChangeNotifier {
}
}
updateCookies(service.cookies);
final rawPreviewResponse = service.lastRawResponse;
// 服务器说这次会话还挂在机上,就按已登录处理:loginDateTime 取当下,
// 风险页与票据照常吃「登录后 60 秒冷却」,不用重新 UserLoginApi。
@@ -177,6 +200,7 @@ class SessionModel extends ChangeNotifier {
reusedCookie: true,
userData: userData,
preview: preview,
rawPreviewResponse: rawPreviewResponse,
loggedIn: _gameLogin != null,
);
} on TitleApiException {
@@ -185,9 +209,9 @@ class SessionModel extends ChangeNotifier {
}
void updateCookies(String? cookies) {
if (cookies != null && cookies.isNotEmpty) {
_cookies = cookies;
}
if (cookies == null || cookies.isEmpty || cookies == _cookies) return;
_cookies = cookies;
notifyListeners();
}
void reset() {
+10 -20
View File
@@ -1,3 +1,5 @@
import '../core/json_value.dart';
/// GetUserRatingApi 之外的角色(旅行伙伴)数据。
///
/// 对应客户端 `Net.VO.Mai2.UserCharacter`:只有 4 个字段,走
@@ -36,30 +38,18 @@ class UserCharacterBean {
useCount: useCount ?? this.useCount,
);
static int _toInt(dynamic v) {
if (v == null) return 0;
if (v is int) return v;
if (v is num) return v.toInt();
if (v is String) return int.tryParse(v) ?? 0;
return 0;
}
factory UserCharacterBean.fromJson(Map<String, dynamic> json) =>
UserCharacterBean(
characterId: _toInt(json['characterId']),
level: _toInt(json['level']),
awakening: _toInt(json['awakening']),
useCount: _toInt(json['useCount']),
characterId: jsonInt(json['characterId']),
level: jsonInt(json['level']),
awakening: jsonInt(json['awakening']),
useCount: jsonInt(json['useCount']),
);
static List<UserCharacterBean> listFromResponse(Map<String, dynamic> json) {
final raw = json['userCharacterList'];
if (raw is! List) return const [];
return raw
.whereType<Map<String, dynamic>>()
.map(UserCharacterBean.fromJson)
.toList();
}
static List<UserCharacterBean> listFromResponse(Map<String, dynamic> json) =>
jsonMapList(json['userCharacterList'])
.map(UserCharacterBean.fromJson)
.toList();
}
/// `UserDetail.charaSlot` 固定 5 个槽,槽 0 是队长
+42 -58
View File
@@ -1,3 +1,5 @@
import '../core/json_value.dart';
class UserDataBean {
final int userId;
final String userName;
@@ -84,67 +86,49 @@ class UserDataBean {
});
factory UserDataBean.fromJson(Map<String, dynamic> json) {
final ud = (json['userData'] as Map<String, dynamic>?) ?? const {};
int toInt(dynamic v) {
if (v == null) return 0;
if (v is int) return v;
if (v is num) return v.toInt();
if (v is String) return int.tryParse(v) ?? 0;
return 0;
}
bool toBool(dynamic v) {
if (v == null) return false;
if (v is bool) return v;
if (v is num) return v != 0;
if (v is String) return v == '1' || v.toLowerCase() == 'true';
return false;
}
String toStr(dynamic v) => v?.toString() ?? '';
final ud = jsonMap(json['userData']);
return UserDataBean(
userId: toInt(json['userId']),
userName: toStr(ud['userName']),
userId: jsonInt(json['userId']),
userName: jsonString(ud['userName']),
friendCode: ud['friendCode']?.toString(),
isNetMember: toBool(ud['isNetMember']),
point: toInt(ud['point']),
totalPoint: toInt(ud['totalPoint']),
playerRating: toInt(ud['playerRating']),
playerOldRating: toInt(ud['playerOldRating']),
playerNewRating: toInt(ud['playerNewRating']),
highestRating: toInt(ud['highestRating']),
musicRating: toInt(ud['musicRating']),
gradeRank: toInt(ud['gradeRank']),
courseRank: toInt(ud['courseRank']),
classRank: toInt(ud['classRank']),
nameplateId: toInt(ud['nameplateId']),
frameId: toInt(ud['frameId']),
iconId: toInt(ud['iconId']),
trophyId: toInt(ud['trophyId']),
plateId: toInt(ud['plateId']),
titleId: toInt(ud['titleId']),
partnerId: toInt(ud['partnerId']),
playCount: toInt(ud['playCount']),
currentPlayCount: toInt(ud['currentPlayCount']),
totalSync: toInt(ud['totalSync']),
totalAchievement: toInt(ud['totalAchievement']),
totalDeluxscore: toInt(ud['totalDeluxscore']),
totalAwake: toInt(ud['totalAwake']),
lastGameId: toStr(ud['lastGameId']),
lastRomVersion: toStr(ud['lastRomVersion']),
lastDataVersion: toStr(ud['lastDataVersion']),
lastLoginDate: toStr(ud['lastLoginDate']),
lastPlayDate: toStr(ud['lastPlayDate']),
firstGameId: toStr(ud['firstGameId']),
firstRomVersion: toStr(ud['firstRomVersion']),
firstDataVersion: toStr(ud['firstDataVersion']),
firstPlayDate: toStr(ud['firstPlayDate']),
lastRegionName: toStr(ud['lastRegionName']),
lastRegionId: toInt(ud['lastRegionId']),
dailyBonusDate: toStr(ud['dailyBonusDate']),
banState: toInt(json['banState']),
isNetMember: jsonBool(ud['isNetMember']),
point: jsonInt(ud['point']),
totalPoint: jsonInt(ud['totalPoint']),
playerRating: jsonInt(ud['playerRating']),
playerOldRating: jsonInt(ud['playerOldRating']),
playerNewRating: jsonInt(ud['playerNewRating']),
highestRating: jsonInt(ud['highestRating']),
musicRating: jsonInt(ud['musicRating']),
gradeRank: jsonInt(ud['gradeRank']),
courseRank: jsonInt(ud['courseRank']),
classRank: jsonInt(ud['classRank']),
nameplateId: jsonInt(ud['nameplateId']),
frameId: jsonInt(ud['frameId']),
iconId: jsonInt(ud['iconId']),
trophyId: jsonInt(ud['trophyId']),
plateId: jsonInt(ud['plateId']),
titleId: jsonInt(ud['titleId']),
partnerId: jsonInt(ud['partnerId']),
playCount: jsonInt(ud['playCount']),
currentPlayCount: jsonInt(ud['currentPlayCount']),
totalSync: jsonInt(ud['totalSync']),
totalAchievement: jsonInt(ud['totalAchievement']),
totalDeluxscore: jsonInt(ud['totalDeluxscore']),
totalAwake: jsonInt(ud['totalAwake']),
lastGameId: jsonString(ud['lastGameId']),
lastRomVersion: jsonString(ud['lastRomVersion']),
lastDataVersion: jsonString(ud['lastDataVersion']),
lastLoginDate: jsonString(ud['lastLoginDate']),
lastPlayDate: jsonString(ud['lastPlayDate']),
firstGameId: jsonString(ud['firstGameId']),
firstRomVersion: jsonString(ud['firstRomVersion']),
firstDataVersion: jsonString(ud['firstDataVersion']),
firstPlayDate: jsonString(ud['firstPlayDate']),
lastRegionName: jsonString(ud['lastRegionName']),
lastRegionId: jsonInt(ud['lastRegionId']),
dailyBonusDate: jsonString(ud['dailyBonusDate']),
banState: jsonInt(json['banState']),
);
}
}
+20 -37
View File
@@ -1,3 +1,5 @@
import '../core/json_value.dart';
/// `Net.VO.Mai2.UserKaleidxScope`(万花筒 / 宿命之门),走
/// `upsertUserAll.userKaleidxScopeList`,主键是 `gateId`。
///
@@ -130,47 +132,28 @@ class UserKaleidxScopeBean {
: ''),
);
static int _toInt(dynamic v) {
if (v is int) return v;
if (v is num) return v.toInt();
if (v is String) return int.tryParse(v) ?? 0;
return 0;
}
static bool _toBool(dynamic v) {
if (v is bool) return v;
if (v is num) return v != 0;
if (v is String) return v == '1' || v.toLowerCase() == 'true';
return false;
}
factory UserKaleidxScopeBean.fromJson(Map<String, dynamic> json) =>
UserKaleidxScopeBean(
gateId: _toInt(json['gateId']),
isGateFound: _toBool(json['isGateFound']),
isKeyFound: _toBool(json['isKeyFound']),
isClear: _toBool(json['isClear']),
totalRestLife: _toInt(json['totalRestLife']),
totalAchievement: _toInt(json['totalAchievement']),
totalDeluxscore: _toInt(json['totalDeluxscore']),
bestAchievement: _toInt(json['bestAchievement']),
bestDeluxscore: _toInt(json['bestDeluxscore']),
bestAchievementDate: json['bestAchievementDate']?.toString() ?? '',
bestDeluxscoreDate: json['bestDeluxscoreDate']?.toString() ?? '',
playCount: _toInt(json['playCount']),
clearDate: json['clearDate']?.toString() ?? '',
lastPlayDate: json['lastPlayDate']?.toString() ?? '',
isInfoWatched: _toBool(json['isInfoWatched']),
gateId: jsonInt(json['gateId']),
isGateFound: jsonBool(json['isGateFound']),
isKeyFound: jsonBool(json['isKeyFound']),
isClear: jsonBool(json['isClear']),
totalRestLife: jsonInt(json['totalRestLife']),
totalAchievement: jsonInt(json['totalAchievement']),
totalDeluxscore: jsonInt(json['totalDeluxscore']),
bestAchievement: jsonInt(json['bestAchievement']),
bestDeluxscore: jsonInt(json['bestDeluxscore']),
bestAchievementDate: jsonString(json['bestAchievementDate']),
bestDeluxscoreDate: jsonString(json['bestDeluxscoreDate']),
playCount: jsonInt(json['playCount']),
clearDate: jsonString(json['clearDate']),
lastPlayDate: jsonString(json['lastPlayDate']),
isInfoWatched: jsonBool(json['isInfoWatched']),
);
static List<UserKaleidxScopeBean> listFromResponse(
Map<String, dynamic> json,
) {
final raw = json['userKaleidxScopeList'];
if (raw is! List) return const [];
return raw
.whereType<Map<String, dynamic>>()
.map(UserKaleidxScopeBean.fromJson)
.toList();
}
) => jsonMapList(json['userKaleidxScopeList'])
.map(UserKaleidxScopeBean.fromJson)
.toList();
}
+23 -41
View File
@@ -1,3 +1,5 @@
import '../core/json_value.dart';
class UserPreviewDataBean {
final int userId;
final String userName;
@@ -49,48 +51,28 @@ class UserPreviewDataBean {
});
factory UserPreviewDataBean.fromJson(Map<String, dynamic> json) {
int toInt(dynamic v) {
if (v == null) return 0;
if (v is int) return v;
if (v is String) return int.tryParse(v) ?? 0;
if (v is double) return v.toInt();
return 0;
}
bool toBool(dynamic v) {
if (v == null) return false;
if (v is bool) return v;
if (v is int) return v != 0;
if (v is String) return v == '1' || v.toLowerCase() == 'true';
return false;
}
String toStr(dynamic v) => v?.toString() ?? '';
return UserPreviewDataBean(
userId: toInt(json['userId']),
userName: toStr(json['userName']),
isLogin: toBool(json['isLogin']),
loginId: toInt(json['loginId']),
lastGameId: toStr(json['lastGameId']),
lastRomVersion: toStr(json['lastRomVersion']),
lastDataVersion: toStr(json['lastDataVersion']),
lastLoginDate: toStr(json['lastLoginDate']),
lastPlayDate: toStr(json['lastPlayDate']),
playerRating: toInt(json['playerRating']),
nameplateId: toInt(json['nameplateId']),
iconId: toInt(json['iconId']),
trophyId: toInt(json['trophyId']),
isNetMember: toBool(json['isNetMember']),
isInherit: toBool(json['isInherit']),
totalAwake: toInt(json['totalAwake']),
dispRate: toInt(json['dispRate']),
dailyBonusDate: toStr(json['dailyBonusDate']),
headPhoneVolume: (json['headPhoneVolume'] is String)
? (int.tryParse(json['headPhoneVolume']) ?? 0)
: toInt(json['headPhoneVolume']),
banState: toInt(json['banState']),
errorId: toInt(json['errorId']),
userId: jsonInt(json['userId']),
userName: jsonString(json['userName']),
isLogin: jsonBool(json['isLogin']),
loginId: jsonInt(json['loginId']),
lastGameId: jsonString(json['lastGameId']),
lastRomVersion: jsonString(json['lastRomVersion']),
lastDataVersion: jsonString(json['lastDataVersion']),
lastLoginDate: jsonString(json['lastLoginDate']),
lastPlayDate: jsonString(json['lastPlayDate']),
playerRating: jsonInt(json['playerRating']),
nameplateId: jsonInt(json['nameplateId']),
iconId: jsonInt(json['iconId']),
trophyId: jsonInt(json['trophyId']),
isNetMember: jsonBool(json['isNetMember']),
isInherit: jsonBool(json['isInherit']),
totalAwake: jsonInt(json['totalAwake']),
dispRate: jsonInt(json['dispRate']),
dailyBonusDate: jsonString(json['dailyBonusDate']),
headPhoneVolume: jsonInt(json['headPhoneVolume']),
banState: jsonInt(json['banState']),
errorId: jsonInt(json['errorId']),
);
}
}
+12 -32
View File
@@ -1,3 +1,5 @@
import '../core/json_value.dart';
/// GetUserRatingApi 的 `userRating` 结构,移植自 Empurple 的 `UserRatingData`。
///
/// 服务端已按 RA 排好 BEST 35 / BEST 15,客户端不再重排。
@@ -43,21 +45,13 @@ class UserRatingItemBean {
MusicLevel get musicLevel => MusicLevel.fromInt(level);
factory UserRatingItemBean.fromJson(Map<String, dynamic> json) {
int toInt(dynamic v) {
if (v == null) return 0;
if (v is int) return v;
if (v is num) return v.toInt();
if (v is String) return int.tryParse(v) ?? 0;
return 0;
}
return UserRatingItemBean(
musicId: toInt(json['musicId']),
level: toInt(json['level']),
achievement: toInt(json['achievement']),
comboStatus: toInt(json['comboStatus']),
syncStatus: toInt(json['syncStatus']),
romVersion: toInt(json['romVersion']),
musicId: jsonInt(json['musicId']),
level: jsonInt(json['level']),
achievement: jsonInt(json['achievement']),
comboStatus: jsonInt(json['comboStatus']),
syncStatus: jsonInt(json['syncStatus']),
romVersion: jsonInt(json['romVersion']),
);
}
}
@@ -73,28 +67,14 @@ class UserRatingBean {
required this.newRatingList,
});
static List<UserRatingItemBean> _parseList(dynamic raw) {
if (raw is! List) return const [];
return raw
.whereType<Map<String, dynamic>>()
.map(UserRatingItemBean.fromJson)
.toList();
}
static List<UserRatingItemBean> _parseList(dynamic raw) =>
jsonMapList(raw).map(UserRatingItemBean.fromJson).toList();
factory UserRatingBean.fromJson(Map<String, dynamic> json) {
int toInt(dynamic v) {
if (v == null) return 0;
if (v is int) return v;
if (v is num) return v.toInt();
if (v is String) return int.tryParse(v) ?? 0;
return 0;
}
final userRating =
(json['userRating'] as Map<String, dynamic>?) ?? const {};
final userRating = jsonMap(json['userRating']);
return UserRatingBean(
userId: toInt(json['userId']),
userId: jsonInt(json['userId']),
ratingList: _parseList(userRating['ratingList']),
newRatingList: _parseList(userRating['newRatingList']),
);
+6 -5
View File
@@ -6,6 +6,7 @@ import 'package:flutter/rendering.dart';
import '../config/responsive.dart';
import '../config/strings.dart';
import '../config/title_server_config.dart';
import '../models/session_model.dart';
import '../models/user_rating.dart';
import '../services/music_data_service.dart';
import '../services/png_export_service.dart';
@@ -56,7 +57,7 @@ class _Best50PageState extends State<Best50Page> {
@override
void didUpdateWidget(Best50Page oldWidget) {
super.didUpdateWidget(oldWidget);
// 本页在 IndexedStack 里会先于 preview/登录请求被构建,cookies 到位后补一次加载。
// 用户可能在 preview/登录请求完成前就切到本页,cookies 到位后再补一次加载。
if (oldWidget.cookies != widget.cookies &&
(_rating == null || _error != null)) {
_bootstrap();
@@ -75,7 +76,9 @@ class _Best50PageState extends State<Best50Page> {
});
try {
final service = TitleApiService.fromHolder(cookies: widget.cookies)!;
final service = SessionModel.instance.createTitleService(
cookies: widget.cookies,
)!;
final rating = await service.getUserRatingTyped(widget.userId);
if (!mounted) return;
setState(() {
@@ -87,10 +90,8 @@ class _Best50PageState extends State<Best50Page> {
await MusicDataService.instance.load();
if (!mounted) return;
setState(() => _musicReady = true);
} on TitleApiException catch (e) {
_fail(e.message);
} catch (e) {
_fail('$e');
_fail(describeApiError(e));
}
}
+98 -184
View File
@@ -1,10 +1,9 @@
import 'package:flutter/material.dart';
import '../config/responsive.dart';
import '../config/strings.dart';
import '../config/title_server_config.dart';
import '../widgets/app_notice.dart';
import '../widgets/cooldown_mixin.dart';
import '../widgets/risk/risk_widgets.dart';
import 'collectibles_page.dart';
import 'kaleidx_scope_page.dart';
import 'map_traverse_page.dart';
@@ -48,10 +47,19 @@ class _HighRiskFeaturePageState extends State<HighRiskFeaturePage>
@override
int? get cooldownLoginDateTime => widget.loginDateTime;
void _openUnlockMusic() {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => UnlockMusicPage(
void _push(Widget page) {
Navigator.of(context).push(MaterialPageRoute(builder: (_) => page));
}
/// 中转页只是导航入口,七张卡片的差异只有图标、文案和目标页,
/// 用一张表驱动,避免七份几乎相同的 `Navigator.push`。
List<({IconData icon, String title, String desc, Widget page})> get _entries {
return [
(
icon: Icons.lock_open,
title: AppStrings.unlockFeatureTitle,
desc: AppStrings.unlockFeatureDesc,
page: UnlockMusicPage(
userId: widget.userId,
cookies: widget.cookies,
loginDateTime: widget.loginDateTime,
@@ -60,13 +68,11 @@ class _HighRiskFeaturePageState extends State<HighRiskFeaturePage>
onExitToTitle: widget.onExitToTitle,
),
),
);
}
void _openCollectibles() {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => CollectiblesPage(
(
icon: Icons.card_giftcard,
title: AppStrings.collectiblesFeatureTitle,
desc: AppStrings.collectiblesFeatureDesc,
page: CollectiblesPage(
userId: widget.userId,
cookies: widget.cookies,
loginDateTime: widget.loginDateTime,
@@ -75,13 +81,11 @@ class _HighRiskFeaturePageState extends State<HighRiskFeaturePage>
onExitToTitle: widget.onExitToTitle,
),
),
);
}
void _openTravelPartner() {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => TravelPartnerPage(
(
icon: Icons.workspace_premium_outlined,
title: AppStrings.travelPartnerFeatureTitle,
desc: AppStrings.travelPartnerFeatureDesc,
page: TravelPartnerPage(
userId: widget.userId,
cookies: widget.cookies,
loginDateTime: widget.loginDateTime,
@@ -89,28 +93,37 @@ class _HighRiskFeaturePageState extends State<HighRiskFeaturePage>
onExitToTitle: widget.onExitToTitle,
),
),
);
}
void _openNumberPatch(NumberPatchMode mode) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => NumberPatchPage(
(
icon: Icons.speed,
title: AppStrings.ratingFeatureTitle,
desc: AppStrings.ratingFeatureDesc,
page: NumberPatchPage(
userId: widget.userId,
cookies: widget.cookies,
loginDateTime: widget.loginDateTime,
loginId: widget.loginId,
onExitToTitle: widget.onExitToTitle,
mode: mode,
mode: NumberPatchMode.rating,
),
),
);
}
void _openMapTraverse() {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => MapTraversePage(
(
icon: Icons.commute,
title: AppStrings.maiMileFeatureTitle,
desc: AppStrings.maiMileFeatureDesc,
page: NumberPatchPage(
userId: widget.userId,
cookies: widget.cookies,
loginDateTime: widget.loginDateTime,
loginId: widget.loginId,
onExitToTitle: widget.onExitToTitle,
mode: NumberPatchMode.maiMile,
),
),
(
icon: Icons.map_outlined,
title: AppStrings.mapFeatureTitle,
desc: AppStrings.mapFeatureDesc,
page: MapTraversePage(
userId: widget.userId,
cookies: widget.cookies,
loginDateTime: widget.loginDateTime,
@@ -118,13 +131,11 @@ class _HighRiskFeaturePageState extends State<HighRiskFeaturePage>
onExitToTitle: widget.onExitToTitle,
),
),
);
}
void _openKaleidxScope() {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => KaleidxScopePage(
(
icon: Icons.science_outlined,
title: AppStrings.kaleidxFeatureTitle,
desc: AppStrings.kaleidxFeatureDesc,
page: KaleidxScopePage(
userId: widget.userId,
cookies: widget.cookies,
loginDateTime: widget.loginDateTime,
@@ -132,167 +143,70 @@ class _HighRiskFeaturePageState extends State<HighRiskFeaturePage>
onExitToTitle: widget.onExitToTitle,
),
),
);
];
}
@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 const RiskConfigMissingView(
message: AppStrings.ticketNotConfigured,
);
}
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: responsiveBody(
context,
child: Column(
children: [
if (!isLoggedIn)
AppNotice(
AppStrings.musicRiskHubNotLoggedIn,
error: true,
icon: Icons.warning_amber_rounded,
),
if (isLoggedIn && cooldownRemaining > 0)
AppNotice(
AppStrings.musicRiskHubCooldownNotice(cooldownRemaining),
icon: Icons.timer_outlined,
),
_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,
),
const SizedBox(height: 12),
_buildNavCard(
theme,
icon: Icons.workspace_premium_outlined,
title: AppStrings.travelPartnerFeatureTitle,
desc: AppStrings.travelPartnerFeatureDesc,
onTap: _openTravelPartner,
),
const SizedBox(height: 12),
_buildNavCard(
theme,
icon: Icons.speed,
title: AppStrings.ratingFeatureTitle,
desc: AppStrings.ratingFeatureDesc,
onTap: () => _openNumberPatch(NumberPatchMode.rating),
),
const SizedBox(height: 12),
_buildNavCard(
theme,
icon: Icons.commute,
title: AppStrings.maiMileFeatureTitle,
desc: AppStrings.maiMileFeatureDesc,
onTap: () => _openNumberPatch(NumberPatchMode.maiMile),
),
const SizedBox(height: 12),
_buildNavCard(
theme,
icon: Icons.map_outlined,
title: AppStrings.mapFeatureTitle,
desc: AppStrings.mapFeatureDesc,
onTap: _openMapTraverse,
),
const SizedBox(height: 12),
_buildNavCard(
theme,
icon: Icons.science_outlined,
title: AppStrings.kaleidxFeatureTitle,
desc: AppStrings.kaleidxFeatureDesc,
onTap: _openKaleidxScope,
),
],
final entries = _entries;
return RiskPageBody(
children: [
RiskStatusNotices(
isLoggedIn: isLoggedIn,
cooldownRemaining: cooldownRemaining,
notLoggedInText: AppStrings.musicRiskHubNotLoggedIn,
cooldownText: AppStrings.musicRiskHubCooldownNotice,
),
),
const RiskFeatureCard(
icon: Icons.warning_amber_rounded,
title: AppStrings.musicRiskHubTitle,
description: AppStrings.musicRiskHubDesc,
danger: true,
),
for (var i = 0; i < entries.length; i++) ...[
const SizedBox(height: 12),
_NavCard(
icon: entries[i].icon,
title: entries[i].title,
desc: entries[i].desc,
onTap: () => _push(entries[i].page),
),
],
],
);
}
}
Widget _buildDescCard(ThemeData theme) {
class _NavCard extends StatelessWidget {
final IconData icon;
final String title;
final String desc;
final VoidCallback onTap;
const _NavCard({
required this.icon,
required this.title,
required this.desc,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: theme.colorScheme.error.withValues(alpha: 0.4),
color: theme.colorScheme.outline.withValues(alpha: 0.3),
),
),
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,
+23
View File
@@ -0,0 +1,23 @@
import 'package:flutter/material.dart';
import '../../config/strings.dart';
/// 主页底部导航的六个页签,顺序即 [IndexedStack] 的下标。
enum HomeTab {
home(AppStrings.tabHome, Icons.home_outlined, Icons.home),
tickets(
AppStrings.tabTickets,
Icons.confirmation_number_outlined,
Icons.confirmation_number,
),
best50(AppStrings.tabBest50, Icons.bar_chart_outlined, Icons.bar_chart),
risk(AppStrings.tabRisk, Icons.warning_outlined, Icons.inventory_2),
settings(AppStrings.tabSettings, Icons.settings_outlined, Icons.settings),
about(AppStrings.tabAbout, Icons.info_outlined, Icons.info);
final String title;
final IconData icon;
final IconData selectedIcon;
const HomeTab(this.title, this.icon, this.selectedIcon);
}
+306
View File
@@ -0,0 +1,306 @@
import 'package:flutter/material.dart';
import '../../config/responsive.dart';
import '../../config/strings.dart';
import '../../config/title_server_config.dart';
import '../../models/user_data.dart';
import '../../models/user_preview.dart';
import '../../widgets/account_backup_card.dart';
import '../../widgets/app_notice.dart';
import '../../widgets/connection_share_card.dart';
import '../../widgets/home/home_common.dart';
import '../../widgets/home/home_debug_card.dart';
import '../../widgets/home/home_preview_cards.dart';
import '../../widgets/home/home_user_data_cards.dart';
/// 主页第一个页签的内容:账号概要、统计卡与调试信息。
///
/// 数据与回调都由 [HomePage] 持有,这里只负责按加载状态切换展示。
class HomeTabView extends StatelessWidget {
final int userId;
final String token;
final String? cookies;
final UserPreviewDataBean? preview;
final UserDataBean? userData;
final bool loading;
final String? error;
final bool isGameLoggedIn;
final bool forcePreviewApi;
final bool showRestoredNotice;
final String? rawJson;
final VoidCallback onRetry;
final VoidCallback onOpenSettings;
final VoidCallback onLogin;
const HomeTabView({
super.key,
required this.userId,
required this.token,
required this.cookies,
required this.preview,
required this.userData,
required this.loading,
required this.error,
required this.isGameLoggedIn,
required this.forcePreviewApi,
required this.showRestoredNotice,
required this.rawJson,
required this.onRetry,
required this.onOpenSettings,
required this.onLogin,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
// 开发预览等入口会直接塞入种子数据,此时没配 Title Server 也应该展示。
if (!TitleServerConfigHolder().isConfigured &&
userData == null &&
preview == null) {
return _ConfigMissingView(onOpenSettings: onOpenSettings);
}
if (loading) {
return const Center(child: CircularProgressIndicator());
}
if (error != null) {
return _ErrorView(theme: theme, error: error!, onRetry: onRetry);
}
final data = userData;
if (data != null) {
return _buildFullData(context, data);
}
final p = preview;
if (p == null) {
return const SizedBox.shrink();
}
final showLoginButton = forcePreviewApi && !isGameLoggedIn;
if (p.isLogin || showLoginButton) {
return _buildPreviewOnly(
context,
p,
showLoginButton: showLoginButton,
);
}
return const SizedBox.shrink();
}
Widget _buildPreviewOnly(
BuildContext context,
UserPreviewDataBean preview, {
required bool showLoginButton,
}) {
final theme = Theme.of(context);
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: responsiveBody(
context,
child: Column(
children: [
if (preview.errorId != 0) ErrorIdBanner(errorId: preview.errorId),
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(
showLoginButton
? 'Preview API 模式,点击下方按钮登录。'
: AppStrings.inheritedAccountNotice,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onTertiaryContainer,
),
),
),
if (showLoginButton) ...[
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: onLogin,
icon: const Icon(Icons.login, size: 20),
label: const Text(AppStrings.login),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
),
],
const SizedBox(height: 12),
HomeProfileCard(
userName: preview.userName,
iconId: preview.iconId,
playerRating: preview.playerRating,
),
const SizedBox(height: 12),
...responsiveGrid(
context: context,
children: [
PreviewGameInfoCard(preview: preview),
PreviewStatusCard(preview: preview),
],
),
const SizedBox(height: 12),
ConnectionShareCard(userId: userId, token: token),
if (rawJson != null) ...[
const SizedBox(height: 12),
DebugRawJsonCard(rawJson: rawJson!),
],
],
),
),
);
}
Widget _buildFullData(BuildContext context, UserDataBean data) {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: responsiveBody(
context,
child: Column(
children: [
if (showRestoredNotice)
AppNotice(
AppStrings.sessionRestoredNotice,
icon: Icons.science_outlined,
),
HomeProfileCard(
userName: data.userName,
iconId: data.iconId,
playerRating: data.playerRating,
highestRating: '${data.highestRating}',
),
const SizedBox(height: 12),
...responsiveGrid(
context: context,
children: [
RatingBreakdownCard(data: data),
FirstPlayCard(data: data),
GameInfoFromUserDataCard(data: data),
PlayStatsCard(data: data),
StatusFromUserDataCard(preview: preview, data: data),
UserDataDetailCard(data: data),
],
),
const SizedBox(height: 12),
ConnectionShareCard(userId: userId, token: token),
const SizedBox(height: 12),
AccountBackupCard(userId: userId, cookies: cookies),
if (rawJson != null) ...[
const SizedBox(height: 12),
DebugRawJsonCard(rawJson: rawJson!),
],
],
),
),
);
}
}
class _ConfigMissingView extends StatelessWidget {
final VoidCallback onOpenSettings;
const _ConfigMissingView({required this.onOpenSettings});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.settings_ethernet,
size: 64,
color: theme.colorScheme.primary,
),
const SizedBox(height: 16),
Text(
AppStrings.titleServerNotConfigured,
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
AppStrings.titleServerNotConfiguredDesc,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
FilledButton.icon(
onPressed: onOpenSettings,
icon: const Icon(Icons.settings, size: 20),
label: const Text(AppStrings.openSettings),
),
],
),
),
);
}
}
class _ErrorView extends StatelessWidget {
final ThemeData theme;
final String error;
final VoidCallback onRetry;
const _ErrorView({
required this.theme,
required this.error,
required this.onRetry,
});
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.error_outline,
size: 48,
color: theme.colorScheme.error,
),
const SizedBox(height: 12),
Text(AppStrings.loadFailed, style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
Text(
error,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontFamily: 'monospace',
),
textAlign: TextAlign.center,
),
const SizedBox(height: 20),
FilledButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh, size: 20),
label: const Text(AppStrings.retry),
),
],
),
),
);
}
}
+120 -1049
View File
File diff suppressed because it is too large. Load diff
+89 -210
View File
@@ -1,15 +1,16 @@
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_kaleidx_scope.dart';
import '../services/title_api_service.dart';
import '../services/user_all_payload_builder.dart';
import '../widgets/app_card.dart';
import '../widgets/app_notice.dart';
import '../widgets/cooldown_mixin.dart';
import '../widgets/risk/risk_run_mixin.dart';
import '../widgets/risk/risk_widgets.dart';
import '../widgets/step_progress.dart';
/// 万花筒专区:发现新的宿命之门、获取门的钥匙、设置门的通关状态。
@@ -41,7 +42,7 @@ class KaleidxScopePage extends StatefulWidget {
enum _ClearChoice { keep, cleared, uncleared }
class _KaleidxScopePageState extends State<KaleidxScopePage>
with CooldownMixin<KaleidxScopePage> {
with CooldownMixin<KaleidxScopePage>, RiskRunMixin<KaleidxScopePage> {
final _gateIdController = TextEditingController();
final List<int> _pending = [];
@@ -52,12 +53,6 @@ class _KaleidxScopePageState extends State<KaleidxScopePage>
Map<String, Map<String, dynamic>> _userAllData = const {};
List<UserKaleidxScopeBean> _serverScopes = const [];
bool _loading = false;
bool _running = false;
bool _autoLogout = true;
RiskStep _step = RiskStep.idle;
String _stepMessage = '';
@override
int? get cooldownLoginDateTime => widget.loginDateTime;
@@ -120,11 +115,13 @@ class _KaleidxScopePageState extends State<KaleidxScopePage>
}
Future<void> _fetchData() async {
if (!TitleServerConfigHolder().isConfigured) return;
setState(() => _loading = true);
final service = SessionModel.instance.createTitleService(
cookies: widget.cookies,
);
if (service == null) return;
setRiskFetching(true);
try {
final service = TitleApiService.fromHolder(cookies: widget.cookies)!;
final results = await Future.wait([
service.fetchUserAllData(widget.userId),
service.getUserKaleidxScopes(widget.userId),
@@ -133,28 +130,25 @@ class _KaleidxScopePageState extends State<KaleidxScopePage>
setState(() {
_userAllData = results[0] as Map<String, Map<String, dynamic>>;
_serverScopes = results[1] as List<UserKaleidxScopeBean>;
_loading = false;
riskFetching = false;
});
} catch (e) {
if (!mounted) return;
setState(() => _loading = false);
_snack('${AppStrings.loadFailed}: $e');
setState(() => riskFetching = false);
_snack('${AppStrings.loadFailed}: ${describeApiError(e)}');
}
}
Future<void> _run() async {
if (!TitleServerConfigHolder().isConfigured) {
_snack(AppStrings.ticketNotConfigured);
return;
}
final loginDateTime = widget.loginDateTime;
final loginId = widget.loginId;
if (loginDateTime == null || loginId == null) {
_snack(AppStrings.kaleidxNotLoggedIn);
return;
}
if (cooldownRemaining > 0) {
_snack(AppStrings.kaleidxCooldownNotice(cooldownRemaining));
final guard = riskPreconditionError(
loginDateTime: widget.loginDateTime,
loginId: widget.loginId,
cooldownRemaining: cooldownRemaining,
notLoggedInMessage: AppStrings.kaleidxNotLoggedIn,
cooldownNotice: AppStrings.kaleidxCooldownNotice,
);
if (guard != null) {
_snack(guard);
return;
}
if (_pending.isEmpty) {
@@ -166,15 +160,19 @@ class _KaleidxScopePageState extends State<KaleidxScopePage>
return;
}
setState(() => _running = true);
final loginDateTime = widget.loginDateTime!;
final loginId = widget.loginId!;
setRiskRunning(true);
try {
final service = TitleApiService.fromHolder(cookies: widget.cookies)!;
final service = SessionModel.instance.createTitleService(
cookies: widget.cookies,
)!;
final builder = UserAllPayloadBuilder(service.config);
var data = _userAllData;
if (data.isEmpty || _serverScopes.isEmpty) {
_updateStep(RiskStep.fetchData);
updateRiskStep(RiskStep.fetchData);
final results = await Future.wait([
service.fetchUserAllData(widget.userId),
service.getUserKaleidxScopes(widget.userId),
@@ -188,7 +186,7 @@ class _KaleidxScopePageState extends State<KaleidxScopePage>
}
}
_updateStep(RiskStep.upload);
updateRiskStep(RiskStep.upload);
final packet = builder.build(
userId: widget.userId,
loginId: loginId,
@@ -209,20 +207,12 @@ class _KaleidxScopePageState extends State<KaleidxScopePage>
);
await service.upsertUserAll(packet, widget.userId);
_updateStep(RiskStep.complete, await _verify(service));
if (_autoLogout && widget.onExitToTitle != null) {
await Future.delayed(const Duration(seconds: 2));
if (!mounted) return;
_updateStep(RiskStep.logout, AppStrings.exitingToTitle);
await widget.onExitToTitle!();
}
} on TitleApiException catch (e) {
_updateStep(RiskStep.failed, e.message);
updateRiskStep(RiskStep.complete, await _verify(service));
await autoLogoutAndExit(widget.onExitToTitle);
} catch (e) {
_updateStep(RiskStep.failed, '$e');
reportRiskError(e);
} finally {
if (mounted) setState(() => _running = false);
setRiskRunning(false);
}
}
@@ -232,7 +222,7 @@ class _KaleidxScopePageState extends State<KaleidxScopePage>
try {
rows = await service.getUserKaleidxScopes(widget.userId);
} catch (e) {
return AppStrings.kaleidxVerifyFailed('$e');
return AppStrings.kaleidxVerifyFailed(describeApiError(e));
}
if (mounted) setState(() => _serverScopes = rows);
@@ -246,106 +236,73 @@ class _KaleidxScopePageState extends State<KaleidxScopePage>
return AppStrings.kaleidxVerified(saved.length);
}
void _updateStep(RiskStep step, [String? message]) {
if (!mounted) return;
setState(() {
_step = step;
_stepMessage = message ?? '';
});
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(title: const Text(AppStrings.kaleidxFeatureTitle)),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: responsiveBody(
context,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (!isLoggedIn)
const AppNotice(AppStrings.kaleidxNotLoggedIn, error: true),
if (isLoggedIn && cooldownRemaining > 0)
AppNotice(
AppStrings.kaleidxCooldownNotice(cooldownRemaining),
),
AppCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SectionTitle(
icon: Icons.science_outlined,
title: AppStrings.kaleidxFeatureTitle,
),
const SizedBox(height: 12),
Text(
AppStrings.kaleidxFeatureDesc,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
height: 1.5,
),
),
],
),
),
const SizedBox(height: 12),
_inputCard(theme),
const SizedBox(height: 12),
_fetchCard(theme),
const SizedBox(height: 12),
AutoLogoutToggle(
value: _autoLogout,
enabled: widget.onExitToTitle != null && !_running,
onChanged: (v) => setState(() => _autoLogout = v),
),
const SizedBox(height: 12),
_runButton(theme),
if (_step != RiskStep.idle) ...[
const SizedBox(height: 16),
StepProgressCard(
step: _step,
message: _stepMessage,
steps: [
(RiskStep.fetchData, AppStrings.kaleidxStepFetch),
(RiskStep.upload, AppStrings.kaleidxStepUpload),
if (_autoLogout) (RiskStep.logout, AppStrings.stepLogout),
],
),
],
],
),
),
),
);
}
Widget _checkRow({
required String label,
required bool value,
required bool enabled,
required ValueChanged<bool> onChanged,
}) {
return InkWell(
borderRadius: BorderRadius.circular(10),
onTap: enabled ? () => onChanged(!value) : null,
child: Row(
body: RiskPageBody(
children: [
Checkbox(
value: value,
onChanged: enabled ? (v) => onChanged(v ?? false) : null,
RiskStatusNotices(
isLoggedIn: isLoggedIn,
cooldownRemaining: cooldownRemaining,
notLoggedInText: AppStrings.kaleidxNotLoggedIn,
cooldownText: AppStrings.kaleidxCooldownNotice,
),
Expanded(child: Text(label)),
RiskFeatureCard(
icon: Icons.science_outlined,
title: AppStrings.kaleidxFeatureTitle,
description: AppStrings.kaleidxFeatureDesc,
),
const SizedBox(height: 12),
_inputCard(theme),
const SizedBox(height: 12),
RiskFetchCard(
hasData: _userAllData.isNotEmpty,
statusText: _userAllData.isNotEmpty
? '${AppStrings.kaleidxFetched} (${_serverScopes.length})'
: AppStrings.kaleidxNoData,
loadingLabel: AppStrings.travelPartnerFetching,
actionLabel: AppStrings.kaleidxFetchData,
loading: riskFetching,
enabled: !riskRunning,
onFetch: _fetchData,
),
const SizedBox(height: 12),
AutoLogoutToggle(
value: riskAutoLogout,
enabled: widget.onExitToTitle != null && !riskRunning,
onChanged: setRiskAutoLogout,
),
const SizedBox(height: 12),
RiskRunButton(
running: riskRunning,
enabled: isLoggedIn,
icon: Icons.lock_open_outlined,
label: AppStrings.kaleidxRun,
runningLabel: AppStrings.kaleidxRunning,
onPressed: _run,
),
if (riskStep != RiskStep.idle) ...[
const SizedBox(height: 16),
StepProgressCard(
step: riskStep,
message: riskStepMessage,
steps: [
(RiskStep.fetchData, AppStrings.kaleidxStepFetch),
(RiskStep.upload, AppStrings.kaleidxStepUpload),
if (riskAutoLogout) (RiskStep.logout, AppStrings.stepLogout),
],
),
],
],
),
);
}
Widget _inputCard(ThemeData theme) {
final enabled = !_running;
final enabled = !riskRunning;
return AppCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
@@ -376,13 +333,13 @@ class _KaleidxScopePageState extends State<KaleidxScopePage>
),
),
const SizedBox(height: 4),
_checkRow(
RiskCheckRow(
label: AppStrings.kaleidxActionDiscover,
value: _discover,
enabled: enabled,
onChanged: (v) => setState(() => _discover = v),
),
_checkRow(
RiskCheckRow(
label: AppStrings.kaleidxActionKey,
value: _giveKey,
enabled: enabled,
@@ -466,82 +423,4 @@ class _KaleidxScopePageState extends State<KaleidxScopePage>
row.isClear,
);
}
Widget _fetchCard(ThemeData theme) {
final hasData = _userAllData.isNotEmpty;
return AppCard(
child: 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.kaleidxFetched} (${_serverScopes.length})'
: AppStrings.kaleidxNoData,
style: theme.textTheme.bodySmall?.copyWith(
color: hasData
? Colors.green
: theme.colorScheme.onSurfaceVariant,
),
),
),
SizedBox(
height: 32,
child: OutlinedButton.icon(
onPressed: _loading || _running ? null : _fetchData,
icon: _loading
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.refresh, size: 16),
label: Text(
_loading
? AppStrings.travelPartnerFetching
: AppStrings.kaleidxFetchData,
style: theme.textTheme.labelSmall,
),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
),
],
),
);
}
Widget _runButton(ThemeData theme) {
return SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: _running || !isLoggedIn ? null : _run,
icon: _running
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.lock_open_outlined, size: 20),
label: Text(
_running ? AppStrings.kaleidxRunning : AppStrings.kaleidxRun,
),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
);
}
}
+20 -25
View File
@@ -6,6 +6,7 @@ import '../models/session_model.dart';
import '../models/session_share.dart';
import '../services/file_picker_service.dart';
import '../services/qr_service.dart';
import '../services/title_api_service.dart';
import '../widgets/app_notice.dart';
import 'home_page.dart';
import 'settings_page.dart';
@@ -22,7 +23,6 @@ class LoginPage extends StatefulWidget {
class _LoginPageState extends State<LoginPage> {
final _qrController = TextEditingController();
String _qrCode = '';
String _rawInput = '';
SessionShare? _share;
bool _loading = false;
@@ -38,7 +38,6 @@ class _LoginPageState extends State<LoginPage> {
final text = value.trim();
setState(() {
_rawInput = text;
_qrCode = SessionModel.extractQRCode(text);
_share = SessionShare.tryDecode(text);
});
}
@@ -51,12 +50,9 @@ class _LoginPageState extends State<LoginPage> {
final result = await decodeQRFromBytes(bytes);
if (result != null && result.isNotEmpty) {
if (!mounted) return;
_qrController.text = result;
setState(() {
_rawInput = result.trim();
_qrCode = SessionModel.extractQRCode(result);
_share = SessionShare.tryDecode(result);
});
_onQRContentChanged(result);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text(AppStrings.qrScanSuccess)),
@@ -79,18 +75,11 @@ class _LoginPageState extends State<LoginPage> {
}
Future<void> _onLogin() async {
if (_qrCode.isEmpty) return;
// 令牌是粘贴内容的后 64 位,前缀可能在截取之后就没了,所以要拿原文判断;
// 认不出来时必须给提示,否则点了按钮一声不响,像是坏了。
if (!_rawInput.contains('SGWCMAID')) {
context.showSnack(AppStrings.qrTokenUnrecognized);
return;
}
if (_rawInput.isEmpty) return;
setState(() => _loading = true);
try {
final result = await SessionModel.instance.loginWithQr(_qrCode);
final result = await SessionModel.instance.loginWithQr(_rawInput);
if (!mounted) return;
@@ -105,18 +94,22 @@ class _LoginPageState extends State<LoginPage> {
),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'${AppStrings.loginFailed} (errorID: ${result.errorId})',
),
),
context.showSnack(
'${AppStrings.loginFailed}:'
'${result.message ?? 'errorID=${result.errorId}'}',
);
}
} on FormatException catch (e) {
// 令牌格式不对 / 没配 Keychip:登录请求根本没发出去,要说清楚是哪一种。
if (mounted) {
context.showSnack('${AppStrings.qrTokenUnrecognized}:${e.message}');
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${AppStrings.requestFailed}: $e')),
SnackBar(
content: Text('${AppStrings.requestFailed}: ${describeApiError(e)}'),
),
);
}
} finally {
@@ -155,7 +148,9 @@ class _LoginPageState extends State<LoginPage> {
);
} catch (e) {
if (mounted) {
context.showSnack('${AppStrings.sessionRestoreFailed}: $e');
context.showSnack(
'${AppStrings.sessionRestoreFailed}: ${describeApiError(e)}',
);
}
} finally {
if (mounted) setState(() => _loading = false);
@@ -285,7 +280,7 @@ class _LoginPageState extends State<LoginPage> {
child: FilledButton.icon(
onPressed: isRestore
? (_loading ? null : _onRestoreSession)
: (_qrCode.isNotEmpty && !_loading)
: (_rawInput.isNotEmpty && !_loading)
? _onLogin
: null,
icon: _loading
+101 -196
View File
@@ -1,14 +1,16 @@
import 'package:flutter/material.dart';
import '../config/responsive.dart';
import '../config/strings.dart';
import '../config/title_server_config.dart';
import '../core/json_value.dart';
import '../models/session_model.dart';
import '../services/title_api_service.dart';
import '../services/user_all_payload_builder.dart';
import '../widgets/app_card.dart';
import '../widgets/app_notice.dart';
import '../widgets/cooldown_mixin.dart';
import '../widgets/risk/risk_run_mixin.dart';
import '../widgets/risk/risk_widgets.dart';
import '../widgets/step_progress.dart';
/// 一键跑图:把选中的区域标记为已完成。
@@ -43,7 +45,7 @@ class MapTraversePage extends StatefulWidget {
}
class _MapTraversePageState extends State<MapTraversePage>
with CooldownMixin<MapTraversePage> {
with CooldownMixin<MapTraversePage>, RiskRunMixin<MapTraversePage> {
final _mapIdController = TextEditingController();
final List<int> _pending = [];
@@ -51,12 +53,6 @@ class _MapTraversePageState extends State<MapTraversePage>
Set<int>? _serverMapIds;
List<Map<String, dynamic>> _serverMaps = const [];
bool _loading = false;
bool _running = false;
bool _autoLogout = true;
RiskStep _step = RiskStep.idle;
String _stepMessage = '';
@override
int? get cooldownLoginDateTime => widget.loginDateTime;
@@ -87,17 +83,19 @@ class _MapTraversePageState extends State<MapTraversePage>
void _applyServerMaps(List<Map<String, dynamic>> rows) {
_serverMaps = rows;
_serverMapIds = rows
.map((e) => (e['mapId'] as num?)?.toInt() ?? 0)
.map((e) => jsonInt(e['mapId']))
.where((e) => e != 0)
.toSet();
}
Future<void> _fetchData() async {
if (!TitleServerConfigHolder().isConfigured) return;
setState(() => _loading = true);
final service = SessionModel.instance.createTitleService(
cookies: widget.cookies,
);
if (service == null) return;
setRiskFetching(true);
try {
final service = TitleApiService.fromHolder(cookies: widget.cookies)!;
final results = await Future.wait([
service.fetchUserAllData(widget.userId),
service.getUserMaps(widget.userId),
@@ -106,28 +104,25 @@ class _MapTraversePageState extends State<MapTraversePage>
setState(() {
_userAllData = results[0] as Map<String, Map<String, dynamic>>;
_applyServerMaps(results[1] as List<Map<String, dynamic>>);
_loading = false;
riskFetching = false;
});
} catch (e) {
if (!mounted) return;
setState(() => _loading = false);
_snack('${AppStrings.loadFailed}: $e');
setState(() => riskFetching = false);
_snack('${AppStrings.loadFailed}: ${describeApiError(e)}');
}
}
Future<void> _run() async {
if (!TitleServerConfigHolder().isConfigured) {
_snack(AppStrings.ticketNotConfigured);
return;
}
final loginDateTime = widget.loginDateTime;
final loginId = widget.loginId;
if (loginDateTime == null || loginId == null) {
_snack(AppStrings.mapNotLoggedIn);
return;
}
if (cooldownRemaining > 0) {
_snack(AppStrings.mapCooldownNotice(cooldownRemaining));
final guard = riskPreconditionError(
loginDateTime: widget.loginDateTime,
loginId: widget.loginId,
cooldownRemaining: cooldownRemaining,
notLoggedInMessage: AppStrings.mapNotLoggedIn,
cooldownNotice: AppStrings.mapCooldownNotice,
);
if (guard != null) {
_snack(guard);
return;
}
if (_pending.isEmpty) {
@@ -135,16 +130,20 @@ class _MapTraversePageState extends State<MapTraversePage>
return;
}
setState(() => _running = true);
final loginDateTime = widget.loginDateTime!;
final loginId = widget.loginId!;
setRiskRunning(true);
try {
final service = TitleApiService.fromHolder(cookies: widget.cookies)!;
final service = SessionModel.instance.createTitleService(
cookies: widget.cookies,
)!;
final builder = UserAllPayloadBuilder(service.config);
var data = _userAllData;
var serverIds = _serverMapIds;
if (data.isEmpty || serverIds == null) {
_updateStep(RiskStep.fetchData);
updateRiskStep(RiskStep.fetchData);
final results = await Future.wait([
service.fetchUserAllData(widget.userId),
service.getUserMaps(widget.userId),
@@ -155,7 +154,7 @@ class _MapTraversePageState extends State<MapTraversePage>
serverIds = _serverMapIds!;
}
_updateStep(RiskStep.upload);
updateRiskStep(RiskStep.upload);
final packet = builder.build(
userId: widget.userId,
loginId: loginId,
@@ -175,20 +174,12 @@ class _MapTraversePageState extends State<MapTraversePage>
builder.applyMapPatch(packet, maps: maps, newFlags: flags);
await service.upsertUserAll(packet, widget.userId);
_updateStep(RiskStep.complete, await _verify(service));
if (_autoLogout && widget.onExitToTitle != null) {
await Future.delayed(const Duration(seconds: 2));
if (!mounted) return;
_updateStep(RiskStep.logout, AppStrings.exitingToTitle);
await widget.onExitToTitle!();
}
} on TitleApiException catch (e) {
_updateStep(RiskStep.failed, e.message);
updateRiskStep(RiskStep.complete, await _verify(service));
await autoLogoutAndExit(widget.onExitToTitle);
} catch (e) {
_updateStep(RiskStep.failed, '$e');
reportRiskError(e);
} finally {
if (mounted) setState(() => _running = false);
setRiskRunning(false);
}
}
@@ -199,7 +190,7 @@ class _MapTraversePageState extends State<MapTraversePage>
try {
rows = await service.getUserMaps(widget.userId);
} catch (e) {
return AppStrings.mapVerifyFailed('$e');
return AppStrings.mapVerifyFailed(describeApiError(e));
}
if (mounted) setState(() => _applyServerMaps(rows));
@@ -211,91 +202,83 @@ class _MapTraversePageState extends State<MapTraversePage>
return AppStrings.mapVerified(requested.length);
}
void _updateStep(RiskStep step, [String? message]) {
if (!mounted) return;
setState(() {
_step = step;
_stepMessage = message ?? '';
});
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(title: const Text(AppStrings.mapFeatureTitle)),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: responsiveBody(
context,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (!isLoggedIn)
const AppNotice(AppStrings.mapNotLoggedIn, error: true),
if (isLoggedIn && cooldownRemaining > 0)
AppNotice(AppStrings.mapCooldownNotice(cooldownRemaining)),
AppCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SectionTitle(
icon: Icons.map_outlined,
title: AppStrings.mapFeatureTitle,
),
const SizedBox(height: 12),
Text(
AppStrings.mapFeatureDesc,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
height: 1.5,
),
),
const SizedBox(height: 10),
Text(
AppStrings.mapCollectiblesNotice,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error,
height: 1.5,
),
),
],
),
),
const SizedBox(height: 12),
_inputCard(theme),
const SizedBox(height: 12),
_fetchCard(theme),
const SizedBox(height: 12),
AutoLogoutToggle(
value: _autoLogout,
enabled: widget.onExitToTitle != null && !_running,
onChanged: (v) => setState(() => _autoLogout = v),
),
const SizedBox(height: 12),
_runButton(theme),
if (_step != RiskStep.idle) ...[
const SizedBox(height: 16),
StepProgressCard(
step: _step,
message: _stepMessage,
steps: [
(RiskStep.fetchData, AppStrings.mapStepFetch),
(RiskStep.upload, AppStrings.mapStepUpload),
if (_autoLogout) (RiskStep.logout, AppStrings.stepLogout),
],
),
],
],
body: RiskPageBody(
children: [
RiskStatusNotices(
isLoggedIn: isLoggedIn,
cooldownRemaining: cooldownRemaining,
notLoggedInText: AppStrings.mapNotLoggedIn,
cooldownText: AppStrings.mapCooldownNotice,
),
),
RiskFeatureCard(
icon: Icons.map_outlined,
title: AppStrings.mapFeatureTitle,
description: AppStrings.mapFeatureDesc,
child: Padding(
padding: const EdgeInsets.only(top: 10),
child: Text(
AppStrings.mapCollectiblesNotice,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error,
height: 1.5,
),
),
),
),
const SizedBox(height: 12),
_inputCard(theme),
const SizedBox(height: 12),
RiskFetchCard(
hasData: _serverMapIds != null,
statusText: _serverMapIds != null
? '${AppStrings.mapFetched} (${_serverMaps.length})'
: AppStrings.mapNoData,
loadingLabel: AppStrings.travelPartnerFetching,
actionLabel: AppStrings.mapFetchData,
loading: riskFetching,
enabled: !riskRunning,
onFetch: _fetchData,
),
const SizedBox(height: 12),
AutoLogoutToggle(
value: riskAutoLogout,
enabled: widget.onExitToTitle != null && !riskRunning,
onChanged: setRiskAutoLogout,
),
const SizedBox(height: 12),
RiskRunButton(
running: riskRunning,
enabled: isLoggedIn,
icon: Icons.flag_outlined,
label: AppStrings.mapRun,
runningLabel: AppStrings.mapRunning,
onPressed: _run,
),
if (riskStep != RiskStep.idle) ...[
const SizedBox(height: 16),
StepProgressCard(
step: riskStep,
message: riskStepMessage,
steps: [
(RiskStep.fetchData, AppStrings.mapStepFetch),
(RiskStep.upload, AppStrings.mapStepUpload),
if (riskAutoLogout) (RiskStep.logout, AppStrings.stepLogout),
],
),
],
],
),
);
}
Widget _inputCard(ThemeData theme) {
final enabled = !_running;
final enabled = !riskRunning;
return AppCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
@@ -341,82 +324,4 @@ class _MapTraversePageState extends State<MapTraversePage>
),
);
}
Widget _fetchCard(ThemeData theme) {
final hasData = _serverMapIds != null;
return AppCard(
child: 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.mapFetched} (${_serverMaps.length})'
: AppStrings.mapNoData,
style: theme.textTheme.bodySmall?.copyWith(
color: hasData
? Colors.green
: theme.colorScheme.onSurfaceVariant,
),
),
),
SizedBox(
height: 32,
child: OutlinedButton.icon(
onPressed: _loading || _running ? null : _fetchData,
icon: _loading
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.refresh, size: 16),
label: Text(
_loading
? AppStrings.travelPartnerFetching
: AppStrings.mapFetchData,
style: theme.textTheme.labelSmall,
),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
),
],
),
);
}
Widget _runButton(ThemeData theme) {
return SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: _running || !isLoggedIn ? null : _run,
icon: _running
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.flag_outlined, size: 20),
label: Text(
_running ? AppStrings.mapRunning : AppStrings.mapRun,
),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
);
}
}
+158 -662
View File
@@ -1,13 +1,17 @@
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 '../services/title_api_service.dart';
import '../services/user_all_payload_builder.dart';
import '../widgets/app_card.dart';
import '../widgets/app_notice.dart';
import '../widgets/cooldown_mixin.dart';
import '../widgets/risk/music_risk_editors.dart';
import '../widgets/risk/risk_list_widgets.dart';
import '../widgets/risk/risk_run_mixin.dart';
import '../widgets/risk/risk_widgets.dart';
import '../widgets/step_progress.dart';
/// Dart port of `eaquira/action/UnlockMusic.py`:
@@ -25,39 +29,6 @@ enum MusicRiskFeatureMode {
collectibles,
}
/// 一行待提交的收藏品道具(对应 wire 上的 UserItem)。
class _PendingItem {
final int kind;
final int id;
const _PendingItem(this.kind, this.id);
}
/// 一行待提交的歌曲解锁,展开成 itemKind 5/6/7 的若干行。
class _PendingMusic {
final int musicId;
final bool base;
final bool master;
final bool remaster;
const _PendingMusic(this.musicId, this.base, this.master, this.remaster);
bool get hasAnyOption => base || master || remaster;
/// 难度由 itemKind 表达,不写在 musicDetail 上。
List<int> get itemKinds => [
if (base) 5,
if (master) 6,
if (remaster) 7,
];
String get optionLabel => [
if (base) '歌曲',
if (master) 'Master',
if (remaster) 'Re:Master',
].join('/');
}
class MusicRiskFeatureView extends StatefulWidget {
final int userId;
final String? cookies;
@@ -96,106 +67,42 @@ class MusicRiskFeatureView extends StatefulWidget {
}
class _MusicRiskFeatureViewState extends State<MusicRiskFeatureView>
with CooldownMixin<MusicRiskFeatureView> {
// ── 解锁选项 (UnlockMusic) ──
final _unlockMusicIdController = TextEditingController();
bool _unlockMusic = false;
bool _unlockMaster = false;
bool _unlockRemaster = false;
// ── 收藏品选项 (Collectibles) ──
final _itemIdController = TextEditingController();
int _itemKind = 3;
with CooldownMixin<MusicRiskFeatureView>, RiskRunMixin<MusicRiskFeatureView> {
// ── 待提交列表:userItemList 可以一次带多行 ──
// 真客户端的 ExportUserItems() 就是把 Plate/Title/Icon/Partner/Frame/Ticket 与
// MusicUnlock/Master/Remaster 各列表拼成多行 UserItem,BuildListData 再只发增量、
// isNewItemList 一行一个字符。所以多行同类/异类混发是客户端自己的做法。
final List<_PendingItem> _pendingItems = [];
final List<_PendingMusic> _pendingMusics = [];
final List<PendingItem> _pendingItems = [];
final List<PendingMusic> _pendingMusics = [];
// ── 运行状态 ──
Map<String, Map<String, dynamic>>? _userAllData;
bool _fetching = false;
String? _fetchError;
bool _running = false;
bool _autoLogout = true;
RiskStep _step = RiskStep.idle;
String _stepMessage = '';
String? _error;
bool get _isUnlock => widget.mode == MusicRiskFeatureMode.unlockMusic;
/// ConstParameter.DxScoreBaseID=10000 / StrongScoreBaseID=20000
/// (`MAI2System/ConstParameter.cs:168,171`)。这段里的歌 Master 需要
/// 显式 itemKind 6,不像 id < 10000 那样在 IsUnlockMaster 里直接放过。
bool _isDeluxeRange(_PendingMusic music) =>
music.musicId >= 10000 && music.musicId < 20000;
@override
int? get cooldownLoginDateTime => widget.loginDateTime;
@override
void dispose() {
_unlockMusicIdController.dispose();
_itemIdController.dispose();
super.dispose();
}
void _updateStep(RiskStep 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;
void _snack(String message) => context.showSnack(message);
/// 把当前输入框里的收藏品加入待提交列表。
void _addItemToList() {
final id = _itemId();
if (id <= 0) {
_snack(AppStrings.collectiblesNeedItemId);
return;
}
if (_pendingItems.any((e) => e.kind == _itemKind && e.id == id)) {
/// 加入待提交收藏品;重复时提示并保留输入。
bool _addItem(PendingItem item) {
if (_pendingItems.any((e) => e.kind == item.kind && e.id == item.id)) {
_snack(AppStrings.listDuplicate);
return;
return false;
}
setState(() {
_pendingItems.add(_PendingItem(_itemKind, id));
_itemIdController.clear();
});
setState(() => _pendingItems.add(item));
return true;
}
/// 把当前输入框里的歌曲+勾选的难度加入待解锁列表。
void _addMusicToList() {
final id = _unlockMusicId();
if (id <= 0) {
_snack(AppStrings.unlockNeedMusicId);
return;
}
if (!_unlockMusic && !_unlockMaster && !_unlockRemaster) {
_snack(AppStrings.unlockNeedOption);
return;
}
if (_pendingMusics.any((e) => e.musicId == id)) {
/// 加入待解锁歌曲;重复时提示并保留输入。
bool _addMusic(PendingMusic music) {
if (_pendingMusics.any((e) => e.musicId == music.musicId)) {
_snack(AppStrings.listDuplicate);
return;
return false;
}
setState(() {
_pendingMusics.add(
_PendingMusic(id, _unlockMusic, _unlockMaster, _unlockRemaster),
);
_unlockMusicIdController.clear();
_unlockMusic = _unlockMaster = _unlockRemaster = false;
});
setState(() => _pendingMusics.add(music));
return true;
}
/// eaquira UnlockMusic.py 使用 settings 中的默认 musicData。
@@ -244,27 +151,29 @@ class _MusicRiskFeatureViewState extends State<MusicRiskFeatureView>
}
Future<void> _fetchData() async {
if (!TitleServerConfigHolder().isConfigured) return;
final service = SessionModel.instance.createTitleService(
cookies: widget.cookies,
);
if (service == null) return;
setState(() {
_fetching = true;
riskFetching = true;
_fetchError = null;
_userAllData = null;
});
try {
final service = TitleApiService.fromHolder(cookies: widget.cookies)!;
final data = await service.fetchUserAllData(widget.userId);
if (!mounted) return;
setState(() {
_userAllData = data;
_fetching = false;
riskFetching = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_fetchError = e.toString();
_fetching = false;
_fetchError = describeApiError(e);
riskFetching = false;
});
}
}
@@ -275,37 +184,36 @@ class _MusicRiskFeatureViewState extends State<MusicRiskFeatureView>
_snack(validationError);
return;
}
if (!TitleServerConfigHolder().isConfigured) {
_snack(AppStrings.ticketNotConfigured);
return;
}
final loginDateTime = widget.loginDateTime;
final loginId = widget.loginId;
if (loginDateTime == null || loginId == null) {
_snack(_notLoggedInMessage);
return;
}
if (cooldownRemaining > 0) {
_snack(_cooldownNotice(cooldownRemaining));
final guard = riskPreconditionError(
loginDateTime: widget.loginDateTime,
loginId: widget.loginId,
cooldownRemaining: cooldownRemaining,
notLoggedInMessage: _notLoggedInMessage,
cooldownNotice: _cooldownNotice,
);
if (guard != null) {
_snack(guard);
return;
}
final service = TitleApiService.fromHolder(cookies: widget.cookies)!;
final loginDateTime = widget.loginDateTime!;
final loginId = widget.loginId!;
final service = SessionModel.instance.createTitleService(
cookies: widget.cookies,
)!;
setState(() {
_running = true;
_error = null;
});
setRiskRunning(true);
clearRiskError();
try {
Map<String, Map<String, dynamic>> data = _userAllData ?? const {};
if (data.isEmpty) {
_updateStep(RiskStep.fetchData);
updateRiskStep(RiskStep.fetchData);
data = await service.fetchUserAllData(widget.userId);
if (mounted) setState(() => _userAllData = data);
}
_updateStep(RiskStep.upload);
updateRiskStep(RiskStep.upload);
final musicData = _buildMusicData();
final builder = UserAllPayloadBuilder(service.config);
final packet = builder.build(
@@ -351,28 +259,19 @@ class _MusicRiskFeatureViewState extends State<MusicRiskFeatureView>
await service.upsertUserAll(packet, widget.userId);
_updateStep(
updateRiskStep(
RiskStep.complete,
_isUnlock
? await _verifyUnlock(service, items)
: AppStrings.collectiblesSuccess,
);
if (_autoLogout && widget.onExitToTitle != null) {
// 让成功状态先显示一下,再交给 HomePage 结算、退登并 pop 回主标题。
await Future.delayed(const Duration(seconds: 2));
if (!mounted) return;
_updateStep(RiskStep.logout, AppStrings.exitingToTitle);
await widget.onExitToTitle!();
}
} on TitleApiException catch (e) {
_updateStep(RiskStep.failed, e.message);
setState(() => _error = e.message);
// 让成功状态先显示一下,再交给 HomePage 结算、退登并 pop 回主标题。
await autoLogoutAndExit(widget.onExitToTitle);
} catch (e) {
_updateStep(RiskStep.failed, e.toString());
setState(() => _error = e.toString());
reportRiskError(e);
} finally {
if (mounted) setState(() => _running = false);
setRiskRunning(false);
}
}
@@ -394,7 +293,7 @@ class _MusicRiskFeatureViewState extends State<MusicRiskFeatureView>
items.map((e) => e['itemKind'] as int),
);
} catch (e) {
return AppStrings.unlockVerifyFailed('$e');
return AppStrings.unlockVerifyFailed(describeApiError(e));
}
final missing = requested.where((k) => !saved.contains(k)).toList();
@@ -414,545 +313,142 @@ class _MusicRiskFeatureViewState extends State<MusicRiskFeatureView>
@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 const RiskConfigMissingView(
message: AppStrings.ticketNotConfigured,
);
}
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: responsiveBody(
context,
child: Column(
children: [
if (!isLoggedIn)
AppNotice(
_notLoggedInMessage,
error: true,
icon: Icons.warning_amber_rounded,
),
if (isLoggedIn && cooldownRemaining > 0)
AppNotice(
_cooldownNotice(cooldownRemaining),
icon: Icons.timer_outlined,
),
_buildDescCard(theme),
const SizedBox(height: 12),
if (_isUnlock)
_buildUnlockCard(theme)
else
_buildCollectiblesCard(theme),
const SizedBox(height: 12),
_buildFetchCard(theme),
const SizedBox(height: 12),
AutoLogoutToggle(
value: _autoLogout,
enabled: widget.onExitToTitle != null && !_running,
onChanged: (v) => setState(() => _autoLogout = v),
),
const SizedBox(height: 12),
_buildRunButton(theme),
if (_step != RiskStep.idle) ...[
const SizedBox(height: 16),
StepProgressCard(
step: _step,
message: _stepMessage,
error: _error,
steps: [
(RiskStep.fetchData, AppStrings.unlockStepFetch),
(
RiskStep.upload,
_isUnlock
? AppStrings.unlockStepUpload
: AppStrings.collectiblesStepUpload,
),
if (_autoLogout) (RiskStep.logout, AppStrings.stepLogout),
],
),
],
],
),
),
);
}
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),
),
const SizedBox(height: 8),
_buildAddButton(theme, onPressed: enabled ? _addMusicToList : null),
_buildPendingList(
theme,
title: AppStrings.unlockPendingTitle,
emptyHint: AppStrings.unlockPendingEmpty,
count: _pendingMusics.length,
rows: [
for (var i = 0; i < _pendingMusics.length; i++)
_buildPendingRow(
theme,
leading: '#${_pendingMusics[i].musicId}',
trailing: _pendingMusics[i].optionLabel,
onRemove: () => setState(() => _pendingMusics.removeAt(i)),
),
],
),
// DX 段的 Master / Re:Master 不白送,勾选项里漏一个就开不出来,
// 这是这段 ID 最容易踩的坑,只在列表真含 DX 曲时才说明。
if (_pendingMusics.any(_isDeluxeRange)) ...[
const SizedBox(height: 10),
Text(
AppStrings.unlockDxRangeNotice,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error,
height: 1.5,
),
),
],
],
),
),
);
}
Widget _buildAddButton(ThemeData theme, {VoidCallback? onPressed}) {
return SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: onPressed,
icon: const Icon(Icons.add, size: 18),
label: const Text(AppStrings.listAddButton),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
),
);
}
Widget _buildPendingList(
ThemeData theme, {
required String title,
required String emptyHint,
required int count,
required List<Widget> rows,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
return RiskPageBody(
children: [
const SizedBox(height: 14),
Text(
'$title ($count)',
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
),
RiskStatusNotices(
isLoggedIn: isLoggedIn,
cooldownRemaining: cooldownRemaining,
notLoggedInText: _notLoggedInMessage,
cooldownText: _cooldownNotice,
),
const SizedBox(height: 6),
if (count == 0)
Text(
emptyHint,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
RiskFeatureCard(
icon: _isUnlock ? Icons.lock_open : Icons.card_giftcard,
title: widget.featureTitle,
description: widget.featureDesc,
),
const SizedBox(height: 12),
if (_isUnlock)
UnlockMusicEditorCard(
enabled: !riskRunning,
onAdd: _addMusic,
onError: _snack,
pendingList: _buildUnlockPendingList(),
showDxNotice: _pendingMusics.any((m) => m.isDeluxeRange),
)
else
...rows,
],
);
}
Widget _buildPendingRow(
ThemeData theme, {
required String leading,
required String trailing,
required VoidCallback onRemove,
}) {
return Row(
children: [
Expanded(
child: Text(
'$leading $trailing',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
),
CollectiblesEditorCard(
enabled: !riskRunning,
onAdd: _addItem,
onError: _snack,
pendingList: _buildCollectiblesPendingList(),
),
const SizedBox(height: 12),
_buildFetchCard(),
const SizedBox(height: 12),
AutoLogoutToggle(
value: riskAutoLogout,
enabled: widget.onExitToTitle != null && !riskRunning,
onChanged: setRiskAutoLogout,
),
IconButton(
icon: const Icon(Icons.close, size: 18),
tooltip: AppStrings.listRemoveTooltip,
onPressed: _running ? null : onRemove,
color: theme.colorScheme.onSurfaceVariant,
visualDensity: VisualDensity.compact,
),
const SizedBox(height: 12),
_buildRunButton(),
if (riskStep != RiskStep.idle) ...[
const SizedBox(height: 16),
StepProgressCard(
step: riskStep,
message: riskStepMessage,
error: riskError,
steps: [
(RiskStep.fetchData, AppStrings.unlockStepFetch),
(
RiskStep.upload,
_isUnlock
? AppStrings.unlockStepUpload
: AppStrings.collectiblesStepUpload,
),
if (riskAutoLogout) (RiskStep.logout, AppStrings.stepLogout),
],
),
],
],
);
}
Widget _buildUnlockCheckbox(
ThemeData theme, {
required bool value,
required String label,
required bool enabled,
required ValueChanged<bool> onChanged,
}) {
return InkWell(
borderRadius: BorderRadius.circular(10),
onTap: enabled ? () => onChanged(!value) : null,
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
child: Row(
children: [
Checkbox(
value: value,
onChanged: enabled ? (v) => onChanged(v ?? false) : null,
),
Expanded(
child: Text(
label,
style: theme.textTheme.bodyMedium?.copyWith(
color: enabled
? null
: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
),
),
),
],
),
),
Widget _buildUnlockPendingList() {
return RiskPendingList(
title: AppStrings.unlockPendingTitle,
emptyHint: AppStrings.unlockPendingEmpty,
count: _pendingMusics.length,
rows: [
for (var i = 0; i < _pendingMusics.length; i++)
RiskPendingRow(
leading: '#${_pendingMusics[i].musicId}',
trailing: _pendingMusics[i].optionLabel,
enabled: !riskRunning,
onRemove: () => setState(() => _pendingMusics.removeAt(i)),
),
],
);
}
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),
),
),
const SizedBox(height: 8),
_buildAddButton(theme, onPressed: enabled ? _addItemToList : null),
_buildPendingList(
theme,
title: AppStrings.collectiblesPendingTitle,
emptyHint: AppStrings.collectiblesPendingEmpty,
count: _pendingItems.length,
rows: [
for (var i = 0; i < _pendingItems.length; i++)
_buildPendingRow(
theme,
leading:
'${AppStrings.collectiblesItemKindName(_pendingItems[i].kind)} (${_pendingItems[i].kind})',
trailing: '#${_pendingItems[i].id}',
onRemove: () => setState(() => _pendingItems.removeAt(i)),
),
],
),
],
),
),
Widget _buildCollectiblesPendingList() {
return RiskPendingList(
title: AppStrings.collectiblesPendingTitle,
emptyHint: AppStrings.collectiblesPendingEmpty,
count: _pendingItems.length,
rows: [
for (var i = 0; i < _pendingItems.length; i++)
RiskPendingRow(
leading:
'${AppStrings.collectiblesItemKindName(_pendingItems[i].kind)} (${_pendingItems[i].kind})',
trailing: '#${_pendingItems[i].id}',
enabled: !riskRunning,
onRemove: () => setState(() => _pendingItems.removeAt(i)),
),
],
);
}
Widget _buildFetchCard(ThemeData theme) {
Widget _buildFetchCard() {
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',
),
),
),
],
],
),
),
return RiskFetchCard(
hasData: hasData,
statusText:
hasData ? AppStrings.unlockFetched : AppStrings.unlockNoData,
loadingLabel: AppStrings.unlockFetching,
actionLabel:
hasData ? AppStrings.unlockRefetch : AppStrings.unlockFetchData,
error: _fetchError,
loading: riskFetching,
enabled: !riskRunning,
onFetch: _fetchData,
);
}
Widget _buildRunButton(ThemeData theme) {
Widget _buildRunButton() {
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 _buildDropdown({
required int value,
required String label,
required List<DropdownMenuItem<int>> items,
required ValueChanged<int> onChanged,
}) {
return DropdownButtonFormField<int>(
initialValue: value,
decoration: InputDecoration(
labelText: label,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
),
contentPadding: const EdgeInsets.all(14),
),
items: items,
onChanged: _running
? null
: (v) {
if (v == null) return;
onChanged(v);
},
return RiskRunButton(
running: riskRunning,
enabled: canRun,
icon: onCooldown
? Icons.timer_outlined
: (_isUnlock ? Icons.lock_open : Icons.card_giftcard),
label: _isUnlock ? AppStrings.unlockMusicRun : AppStrings.collectiblesRun,
runningLabel: AppStrings.unlockRunning,
disabledLabel:
onCooldown ? AppStrings.ticketCooldownCountdown(cooldownRemaining) : null,
onPressed: _run,
);
}
}
+91 -199
View File
@@ -1,14 +1,16 @@
import 'package:flutter/material.dart';
import '../config/responsive.dart';
import '../config/strings.dart';
import '../config/title_server_config.dart';
import '../core/json_value.dart';
import '../models/session_model.dart';
import '../services/title_api_service.dart';
import '../services/user_all_payload_builder.dart';
import '../widgets/app_card.dart';
import '../widgets/app_notice.dart';
import '../widgets/cooldown_mixin.dart';
import '../widgets/risk/risk_run_mixin.dart';
import '../widgets/risk/risk_widgets.dart';
import '../widgets/step_progress.dart';
/// 数值修改:Rating 与舞里程。两者的流程一模一样(拉数据 → 改一个数 →
@@ -98,18 +100,12 @@ class NumberPatchView extends StatefulWidget {
}
class _NumberPatchViewState extends State<NumberPatchView>
with CooldownMixin<NumberPatchView> {
with CooldownMixin<NumberPatchView>, RiskRunMixin<NumberPatchView> {
final _valueController = TextEditingController();
Map<String, Map<String, dynamic>> _userAllData = const {};
bool _maiMileAdd = true;
bool _loading = false;
bool _running = false;
bool _autoLogout = true;
RiskStep _step = RiskStep.idle;
String _stepMessage = '';
bool get _isRating => widget.mode == NumberPatchMode.rating;
@override
@@ -123,18 +119,11 @@ class _NumberPatchViewState extends State<NumberPatchView>
void _snack(String message) => context.showSnack(message);
static int _toInt(dynamic v) {
if (v is int) return v;
if (v is num) return v.toInt();
if (v is String) return int.tryParse(v) ?? 0;
return 0;
}
/// 从已拉取的 GetUserDataApi 里读当前值,供「累加」和界面上的当前值展示。
int _currentOf(Map<String, Map<String, dynamic>> data, String field) {
final userData = data['GetUserDataApi']?['userData'];
if (userData is! Map) return 0;
return _toInt(userData[field]);
return jsonInt(userData[field]);
}
int get _currentRating => _currentOf(_userAllData, 'playerRating');
@@ -172,37 +161,36 @@ class _NumberPatchViewState extends State<NumberPatchView>
}
Future<void> _fetchData() async {
if (!TitleServerConfigHolder().isConfigured) return;
setState(() => _loading = true);
final service = SessionModel.instance.createTitleService(
cookies: widget.cookies,
);
if (service == null) return;
setRiskFetching(true);
try {
final service = TitleApiService.fromHolder(cookies: widget.cookies)!;
final data = await service.fetchUserAllData(widget.userId);
if (!mounted) return;
setState(() {
_userAllData = data;
_loading = false;
riskFetching = false;
});
} catch (e) {
if (!mounted) return;
setState(() => _loading = false);
_snack('${AppStrings.loadFailed}: $e');
setState(() => riskFetching = false);
_snack('${AppStrings.loadFailed}: ${describeApiError(e)}');
}
}
Future<void> _run() async {
if (!TitleServerConfigHolder().isConfigured) {
_snack(AppStrings.ticketNotConfigured);
return;
}
final loginDateTime = widget.loginDateTime;
final loginId = widget.loginId;
if (loginDateTime == null || loginId == null) {
_snack(AppStrings.numberPatchNotLoggedIn);
return;
}
if (cooldownRemaining > 0) {
_snack(AppStrings.numberPatchCooldownNotice(cooldownRemaining));
final guard = riskPreconditionError(
loginDateTime: widget.loginDateTime,
loginId: widget.loginId,
cooldownRemaining: cooldownRemaining,
notLoggedInMessage: AppStrings.numberPatchNotLoggedIn,
cooldownNotice: AppStrings.numberPatchCooldownNotice,
);
if (guard != null) {
_snack(guard);
return;
}
final inputError = _checkInput();
@@ -211,21 +199,26 @@ class _NumberPatchViewState extends State<NumberPatchView>
return;
}
final input = int.parse(_valueController.text.trim());
final loginDateTime = widget.loginDateTime!;
final loginId = widget.loginId!;
setState(() => _running = true);
setRiskRunning(true);
clearRiskError();
try {
final service = TitleApiService.fromHolder(cookies: widget.cookies)!;
final service = SessionModel.instance.createTitleService(
cookies: widget.cookies,
)!;
final builder = UserAllPayloadBuilder(service.config);
var data = _userAllData;
if (data.isEmpty) {
_updateStep(RiskStep.fetchData);
updateRiskStep(RiskStep.fetchData);
data = await service.fetchUserAllData(widget.userId);
if (mounted) setState(() => _userAllData = data);
}
_updateStep(RiskStep.upload);
updateRiskStep(RiskStep.upload);
final packet = builder.build(
userId: widget.userId,
loginId: loginId,
@@ -237,8 +230,7 @@ class _NumberPatchViewState extends State<NumberPatchView>
final String resultMessage;
if (_isRating) {
builder.applyRatingPatch(packet, rating: input);
resultMessage =
'${AppStrings.ratingSuccess} → ${_clamp(input)}';
resultMessage = '${AppStrings.ratingSuccess} → ${_clamp(input)}';
} else {
final current = _currentOf(data, 'point');
final total = _currentOf(data, 'totalPoint');
@@ -253,103 +245,70 @@ class _NumberPatchViewState extends State<NumberPatchView>
}
await service.upsertUserAll(packet, widget.userId);
_updateStep(RiskStep.complete, resultMessage);
if (_autoLogout && widget.onExitToTitle != null) {
await Future.delayed(const Duration(seconds: 2));
if (!mounted) return;
_updateStep(RiskStep.logout, AppStrings.exitingToTitle);
await widget.onExitToTitle!();
}
} on TitleApiException catch (e) {
_updateStep(RiskStep.failed, e.message);
updateRiskStep(RiskStep.complete, resultMessage);
await autoLogoutAndExit(widget.onExitToTitle);
} catch (e) {
_updateStep(RiskStep.failed, '$e');
reportRiskError(e);
} finally {
if (mounted) setState(() => _running = false);
setRiskRunning(false);
}
}
void _updateStep(RiskStep step, [String? message]) {
if (!mounted) return;
setState(() {
_step = step;
_stepMessage = message ?? '';
});
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: responsiveBody(
context,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (!isLoggedIn)
const AppNotice(AppStrings.numberPatchNotLoggedIn, error: true),
if (isLoggedIn && cooldownRemaining > 0)
AppNotice(
AppStrings.numberPatchCooldownNotice(cooldownRemaining),
),
_descCard(theme),
const SizedBox(height: 12),
_inputCard(theme),
const SizedBox(height: 12),
_fetchCard(theme),
const SizedBox(height: 12),
AutoLogoutToggle(
value: _autoLogout,
enabled: widget.onExitToTitle != null && !_running,
onChanged: (v) => setState(() => _autoLogout = v),
),
const SizedBox(height: 12),
_runButton(theme),
if (_step != RiskStep.idle) ...[
const SizedBox(height: 16),
StepProgressCard(
step: _step,
message: _stepMessage,
steps: [
(RiskStep.fetchData, AppStrings.unlockStepFetch),
(RiskStep.upload, AppStrings.numberPatchStepUpload),
if (_autoLogout) (RiskStep.logout, AppStrings.stepLogout),
],
),
],
],
return RiskPageBody(
children: [
RiskStatusNotices(
isLoggedIn: isLoggedIn,
cooldownRemaining: cooldownRemaining,
notLoggedInText: AppStrings.numberPatchNotLoggedIn,
cooldownText: AppStrings.numberPatchCooldownNotice,
),
),
);
}
Widget _descCard(ThemeData theme) {
return AppCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SectionTitle(
icon: _isRating ? Icons.speed : Icons.commute,
title: widget.featureTitle,
),
const SizedBox(height: 12),
Text(
widget.featureDesc,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
height: 1.5,
),
RiskFeatureCard(
icon: _isRating ? Icons.speed : Icons.commute,
title: widget.featureTitle,
description: widget.featureDesc,
),
const SizedBox(height: 12),
_inputCard(theme),
const SizedBox(height: 12),
_fetchCard(),
const SizedBox(height: 12),
AutoLogoutToggle(
value: riskAutoLogout,
enabled: widget.onExitToTitle != null && !riskRunning,
onChanged: setRiskAutoLogout,
),
const SizedBox(height: 12),
RiskRunButton(
running: riskRunning,
enabled: isLoggedIn,
icon: Icons.send,
label: AppStrings.numberPatchRun,
runningLabel: AppStrings.unlockRunning,
onPressed: _run,
),
if (riskStep != RiskStep.idle) ...[
const SizedBox(height: 16),
StepProgressCard(
step: riskStep,
message: riskStepMessage,
error: riskError,
steps: [
(RiskStep.fetchData, AppStrings.unlockStepFetch),
(RiskStep.upload, AppStrings.numberPatchStepUpload),
if (riskAutoLogout) (RiskStep.logout, AppStrings.stepLogout),
],
),
],
),
],
);
}
Widget _inputCard(ThemeData theme) {
final enabled = !_running;
final enabled = !riskRunning;
final hasInput = int.tryParse(_valueController.text.trim()) != null;
return AppCard(
@@ -430,89 +389,22 @@ class _NumberPatchViewState extends State<NumberPatchView>
return AppStrings.numberPatchPreview(label, current, _previewTarget);
}
Widget _fetchCard(ThemeData theme) {
Widget _fetchCard() {
final hasData = _userAllData.isNotEmpty;
final current = _isRating ? _currentRating : _currentPoint;
final label = _isRating
? AppStrings.ratingCurrentValue
: AppStrings.maiMileCurrentBalance;
return AppCard(
child: 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
? '$label: $current'
: AppStrings.unlockNoData,
style: theme.textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
color: hasData
? Colors.green
: theme.colorScheme.onSurfaceVariant,
),
),
),
SizedBox(
height: 32,
child: OutlinedButton.icon(
onPressed: _loading || _running ? null : _fetchData,
icon: _loading
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.refresh, size: 16),
label: Text(
_loading
? 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),
),
),
),
),
],
),
);
}
Widget _runButton(ThemeData theme) {
return SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: _running || !isLoggedIn ? null : _run,
icon: _running
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.send, size: 20),
label: Text(
_running ? AppStrings.unlockRunning : AppStrings.numberPatchRun,
),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
return RiskFetchCard(
hasData: hasData,
statusText: hasData ? '$label: $current' : AppStrings.unlockNoData,
loadingLabel: AppStrings.unlockFetching,
actionLabel:
hasData ? AppStrings.unlockRefetch : AppStrings.unlockFetchData,
loading: riskFetching,
enabled: !riskRunning,
onFetch: _fetchData,
);
}
}
+19 -14
View File
@@ -27,8 +27,6 @@ class _SettingsPageState extends State<SettingsPage> {
final _regionNameController = TextEditingController();
final _placeNameController = TextEditingController();
final _keychipIdController = TextEditingController();
final _aimeUrlController = TextEditingController();
final _aimeSaltController = TextEditingController();
@override
void initState() {
@@ -50,8 +48,6 @@ class _SettingsPageState extends State<SettingsPage> {
_regionNameController.text = config.regionName;
_placeNameController.text = config.placeName;
_keychipIdController.text = config.keychipId;
_aimeUrlController.text = config.aimeUrl;
_aimeSaltController.text = config.aimeSalt;
}
}
@@ -68,8 +64,6 @@ class _SettingsPageState extends State<SettingsPage> {
_regionNameController.dispose();
_placeNameController.dispose();
_keychipIdController.dispose();
_aimeUrlController.dispose();
_aimeSaltController.dispose();
super.dispose();
}
@@ -88,11 +82,19 @@ class _SettingsPageState extends State<SettingsPage> {
regionName: _regionNameController.text.trim(),
placeName: _placeNameController.text.trim(),
keychipId: _keychipIdController.text.trim(),
aimeUrl: _aimeUrlController.text.trim(),
aimeSalt: _aimeSaltController.text.trim(),
));
if (mounted) Navigator.of(context).pop();
if (!mounted) return;
final navigator = Navigator.of(context);
// 作为主页页签嵌入时(showAppBar: false)没有可弹出的路由,
// 直接 pop 会把根路由弹掉,整个导航栈变空只剩黑屏。
if (navigator.canPop()) {
navigator.pop();
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text(AppStrings.configSaved)),
);
}
}
@override
@@ -139,11 +141,11 @@ class _SettingsPageState extends State<SettingsPage> {
]),
const SizedBox(height: 24),
_buildSection(theme, AppStrings.authServerSettings, [
_buildField(controller: _keychipIdController, label: AppStrings.labelKeychipId),
const SizedBox(height: 14),
_buildField(controller: _aimeUrlController, label: AppStrings.labelAimeUrl),
const SizedBox(height: 14),
_buildField(controller: _aimeSaltController, label: AppStrings.labelAimeSalt),
_buildField(
controller: _keychipIdController,
label: AppStrings.labelKeychipId,
help: AppStrings.helpKeychipId,
),
]),
const SizedBox(height: 32),
FilledButton.icon(
@@ -216,11 +218,14 @@ class _SettingsPageState extends State<SettingsPage> {
Widget _buildField({
required TextEditingController controller,
required String label,
String? help,
}) {
return TextFormField(
controller: controller,
decoration: InputDecoration(
labelText: label,
helperText: help,
helperStyle: const TextStyle(fontSize: 12),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
),
+83 -132
View File
@@ -1,12 +1,14 @@
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 '../services/title_api_service.dart';
import '../widgets/app_card.dart';
import '../widgets/app_notice.dart';
import '../widgets/cooldown_mixin.dart';
import '../widgets/risk/risk_run_mixin.dart';
import '../widgets/risk/risk_widgets.dart';
import '../widgets/step_progress.dart';
class TicketPage extends StatefulWidget {
@@ -35,19 +37,13 @@ class TicketPage extends StatefulWidget {
}
class _TicketPageState extends State<TicketPage>
with CooldownMixin<TicketPage> {
with CooldownMixin<TicketPage>, RiskRunMixin<TicketPage> {
static const _ticketNameMap = {
2: '2倍票',
4: '4倍票',
5: '5倍票'
};
RiskStep _step = RiskStep.idle;
String _stepMessage = '';
String? _error;
bool _running = false;
bool _autoLogout = true;
List<Map<String, dynamic>>? _tickets;
bool _ticketsLoading = false;
String? _ticketsError;
@@ -70,16 +66,11 @@ class _TicketPageState extends State<TicketPage>
return null;
}
void _updateStep(RiskStep step, [String? message]) {
if (!mounted) return;
setState(() {
_step = step;
_stepMessage = message ?? '';
});
}
Future<void> _loadTickets() async {
if (!TitleServerConfigHolder().isConfigured) return;
final service = SessionModel.instance.createTitleService(
cookies: widget.cookies,
);
if (service == null) return;
setState(() {
_ticketsLoading = true;
@@ -89,7 +80,6 @@ class _TicketPageState extends State<TicketPage>
});
try {
final service = TitleApiService.fromHolder(cookies: widget.cookies)!;
final data = await service.getUserCharge(widget.userId);
if (!mounted) return;
@@ -101,73 +91,67 @@ class _TicketPageState extends State<TicketPage>
} catch (e) {
if (!mounted) return;
setState(() {
_ticketsError = e.toString();
_ticketsError = describeApiError(e);
_ticketsLoading = false;
});
}
}
Future<void> _runTicket() async {
if (_selectedTicketId == null) {
final ticketId = _selectedTicketId;
if (ticketId == null) {
_snack(AppStrings.ticketNotSelected);
return;
}
if (!TitleServerConfigHolder().isConfigured) {
_snack(AppStrings.ticketNotConfigured);
return;
}
final loginDateTime = widget.loginDateTime;
if (loginDateTime == null) {
_snack(AppStrings.ticketNotLoggedIn);
return;
}
if (cooldownRemaining > 0) {
_snack(AppStrings.ticketCooldownNotice(cooldownRemaining));
final guard = riskPreconditionError(
loginDateTime: widget.loginDateTime,
loginId: null,
cooldownRemaining: cooldownRemaining,
notLoggedInMessage: AppStrings.ticketNotLoggedIn,
cooldownNotice: AppStrings.ticketCooldownNotice,
requireLoginId: false,
);
if (guard != null) {
_snack(guard);
return;
}
final selectedTicket = _findTicket(_selectedTicketId!);
final selectedTicket = _findTicket(ticketId);
if (selectedTicket != null &&
(selectedTicket['stock'] as num?)?.toInt() != 0) {
_snack(AppStrings.ticketStockNotEmpty);
return;
}
final service = TitleApiService.fromHolder(cookies: widget.cookies)!;
final ticketId = _selectedTicketId!;
setState(() {
_running = true;
_error = null;
});
final service = SessionModel.instance.createTitleService(
cookies: widget.cookies,
)!;
setRiskRunning(true);
clearRiskError();
try {
_updateStep(RiskStep.upload);
updateRiskStep(RiskStep.upload);
await service.upsertUserChargeLog(
userId: widget.userId,
ticketId: ticketId,
loginDateTime: loginDateTime,
loginDateTime: widget.loginDateTime!,
playerRating: widget.playerRating,
);
if (_autoLogout && widget.onLogoutRequested != null) {
_updateStep(RiskStep.logout);
await Future.delayed(const Duration(seconds: 5));
if (riskAutoLogout && widget.onLogoutRequested != null) {
updateRiskStep(RiskStep.logout);
await Future<void>.delayed(const Duration(seconds: 5));
await widget.onLogoutRequested!();
}
_updateStep(
updateRiskStep(
RiskStep.complete,
'${AppStrings.ticketUsed}: ${_ticketName(ticketId)}',
);
} on TitleApiException catch (e) {
_updateStep(RiskStep.failed, e.message);
setState(() => _error = e.message);
} catch (e) {
_updateStep(RiskStep.failed, e.toString());
setState(() => _error = e.toString());
reportRiskError(e);
} finally {
if (mounted) setState(() => _running = false);
setRiskRunning(false);
}
}
@@ -176,63 +160,46 @@ class _TicketPageState extends State<TicketPage>
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 const RiskConfigMissingView(
message: AppStrings.ticketNotConfigured,
);
}
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: responsiveBody(
context,
child: Column(
children: [
if (!isLoggedIn)
const AppNotice(
AppStrings.ticketNotLoggedIn,
error: true,
icon: Icons.warning_amber_rounded,
),
if (isLoggedIn && cooldownRemaining > 0)
AppNotice(
AppStrings.ticketCooldownNotice(cooldownRemaining),
icon: Icons.timer_outlined,
),
_buildTicketListCard(theme),
const SizedBox(height: 16),
AutoLogoutToggle(
value: _autoLogout,
enabled: widget.onLogoutRequested != null && !_running,
onChanged: (v) => setState(() => _autoLogout = v),
label: AppStrings.ticketAutoLogout,
),
const SizedBox(height: 12),
_buildRunButton(theme),
if (_step != RiskStep.idle) ...[
const SizedBox(height: 16),
StepProgressCard(
step: _step,
message: _stepMessage,
error: _error,
steps: [
(RiskStep.upload, AppStrings.stepChargeTicket),
if (_autoLogout) (RiskStep.logout, AppStrings.stepLogout),
],
),
],
],
return _buildBody(theme);
}
Widget _buildBody(ThemeData theme) {
return RiskPageBody(
children: [
RiskStatusNotices(
isLoggedIn: isLoggedIn,
cooldownRemaining: cooldownRemaining,
notLoggedInText: AppStrings.ticketNotLoggedIn,
cooldownText: AppStrings.ticketCooldownNotice,
),
),
_buildTicketListCard(theme),
const SizedBox(height: 16),
AutoLogoutToggle(
value: riskAutoLogout,
enabled: widget.onLogoutRequested != null && !riskRunning,
onChanged: setRiskAutoLogout,
label: AppStrings.ticketAutoLogout,
),
const SizedBox(height: 12),
_buildRunButton(theme),
if (riskStep != RiskStep.idle) ...[
const SizedBox(height: 16),
StepProgressCard(
step: riskStep,
message: riskStepMessage,
error: riskError,
steps: [
(RiskStep.upload, AppStrings.stepChargeTicket),
if (riskAutoLogout) (RiskStep.logout, AppStrings.stepLogout),
],
),
],
],
);
}
@@ -327,7 +294,7 @@ class _TicketPageState extends State<TicketPage>
return InkWell(
borderRadius: BorderRadius.circular(8),
onTap: _running
onTap: riskRunning
? null
: () {
setState(() {
@@ -415,7 +382,7 @@ class _TicketPageState extends State<TicketPage>
? _ticketName(_selectedTicketId!)
: null;
final onCooldown = cooldownRemaining > 0;
final canRun = widget.loginDateTime != null && !_running && !onCooldown;
final canRun = widget.loginDateTime != null && !onCooldown;
return Column(
children: [
@@ -437,34 +404,18 @@ class _TicketPageState extends State<TicketPage>
),
const SizedBox(height: 12),
],
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: canRun ? _runTicket : null,
icon: _running
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
)
: Icon(onCooldown ? Icons.timer_outlined : Icons.play_arrow, size: 20),
label: Text(
_running
? AppStrings.runningTicket
: onCooldown
? AppStrings.ticketCooldownCountdown(cooldownRemaining)
: AppStrings.runTicket,
),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
RiskRunButton(
running: riskRunning,
enabled: canRun,
icon: onCooldown ? Icons.timer_outlined : Icons.play_arrow,
label: AppStrings.runTicket,
runningLabel: AppStrings.runningTicket,
disabledLabel: onCooldown
? AppStrings.ticketCooldownCountdown(cooldownRemaining)
: null,
onPressed: _runTicket,
),
],
);
}
}
+261 -365
View File
@@ -1,15 +1,16 @@
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_character.dart';
import '../services/title_api_service.dart';
import '../services/user_all_payload_builder.dart';
import '../widgets/app_card.dart';
import '../widgets/app_notice.dart';
import '../widgets/cooldown_mixin.dart';
import '../widgets/risk/risk_run_mixin.dart';
import '../widgets/risk/risk_widgets.dart';
import '../widgets/step_progress.dart';
/// 旅行伙伴页:发放角色 + 编组出战槽位。
@@ -44,7 +45,7 @@ class TravelPartnerPage extends StatefulWidget {
}
class _TravelPartnerPageState extends State<TravelPartnerPage>
with CooldownMixin<TravelPartnerPage> {
with CooldownMixin<TravelPartnerPage>, RiskRunMixin<TravelPartnerPage> {
final _grantIdController = TextEditingController();
/// 每个出战槽位一个等级输入框,留空表示不改该角色的等级。
@@ -58,12 +59,6 @@ class _TravelPartnerPageState extends State<TravelPartnerPage>
final List<int> _pendingGrant = [];
List<int> _slots = normalizeCharaSlot(const []);
bool _loading = false;
bool _running = false;
bool _autoLogout = true;
RiskStep _step = RiskStep.idle;
String _stepMessage = '';
@override
int? get cooldownLoginDateTime => widget.loginDateTime;
@@ -84,11 +79,13 @@ class _TravelPartnerPageState extends State<TravelPartnerPage>
_pendingGrant.contains(characterId));
Future<void> _fetchData() async {
if (!TitleServerConfigHolder().isConfigured) return;
setState(() => _loading = true);
final service = SessionModel.instance.createTitleService(
cookies: widget.cookies,
);
if (service == null) return;
setRiskFetching(true);
try {
final service = TitleApiService.fromHolder(cookies: widget.cookies)!;
final results = await Future.wait([
service.getUserCharacters(widget.userId),
service.fetchUserAllData(widget.userId),
@@ -109,12 +106,12 @@ class _TravelPartnerPageState extends State<TravelPartnerPage>
_userAllData = all;
_slots = normalizeCharaSlot(slots);
// 槽位里引用了但列表没有的角色,保留为可选,避免下拉框丢项。
_loading = false;
riskFetching = false;
});
} catch (e) {
if (!mounted) return;
setState(() => _loading = false);
_snack('${AppStrings.loadFailed}: $e');
setState(() => riskFetching = false);
_snack('${AppStrings.loadFailed}: ${describeApiError(e)}');
}
}
@@ -192,18 +189,15 @@ class _TravelPartnerPageState extends State<TravelPartnerPage>
}
Future<void> _run() async {
if (!TitleServerConfigHolder().isConfigured) {
_snack(AppStrings.ticketNotConfigured);
return;
}
final loginDateTime = widget.loginDateTime;
final loginId = widget.loginId;
if (loginDateTime == null || loginId == null) {
_snack(AppStrings.travelPartnerNotLoggedIn);
return;
}
if (cooldownRemaining > 0) {
_snack(AppStrings.travelPartnerCooldownNotice(cooldownRemaining));
final guard = riskPreconditionError(
loginDateTime: widget.loginDateTime,
loginId: widget.loginId,
cooldownRemaining: cooldownRemaining,
notLoggedInMessage: AppStrings.travelPartnerNotLoggedIn,
cooldownNotice: AppStrings.travelPartnerCooldownNotice,
);
if (guard != null) {
_snack(guard);
return;
}
final validationError = _validate();
@@ -212,20 +206,25 @@ class _TravelPartnerPageState extends State<TravelPartnerPage>
return;
}
setState(() => _running = true);
final loginDateTime = widget.loginDateTime!;
final loginId = widget.loginId!;
setRiskRunning(true);
clearRiskError();
try {
final service = TitleApiService.fromHolder(cookies: widget.cookies)!;
final service = SessionModel.instance.createTitleService(
cookies: widget.cookies,
)!;
final builder = UserAllPayloadBuilder(service.config);
var data = _userAllData;
if (data.isEmpty) {
_updateStep(RiskStep.fetchData);
updateRiskStep(RiskStep.fetchData);
data = await service.fetchUserAllData(widget.userId);
if (mounted) setState(() => _userAllData = data);
}
_updateStep(RiskStep.upload);
updateRiskStep(RiskStep.upload);
final packet = builder.build(
userId: widget.userId,
loginId: loginId,
@@ -273,25 +272,17 @@ class _TravelPartnerPageState extends State<TravelPartnerPage>
// 两种失败在返回值上完全一样(returnCode 都是 1),只能靠回读区分:
// 服务器没写入 userCharacterList,还是写入了但 ID 不在机台 Chara 表里
// 被客户端 CharacterSelectProces 静默跳过。
_updateStep(
updateRiskStep(
RiskStep.complete,
_pendingGrant.isEmpty
? AppStrings.travelPartnerSuccess
: await _verifyGranted(service),
);
if (_autoLogout && widget.onExitToTitle != null) {
await Future.delayed(const Duration(seconds: 2));
if (!mounted) return;
_updateStep(RiskStep.logout, AppStrings.exitingToTitle);
await widget.onExitToTitle!();
}
} on TitleApiException catch (e) {
_updateStep(RiskStep.failed, e.message);
await autoLogoutAndExit(widget.onExitToTitle);
} catch (e) {
_updateStep(RiskStep.failed, '$e');
reportRiskError(e);
} finally {
if (mounted) setState(() => _running = false);
setRiskRunning(false);
}
}
@@ -301,7 +292,7 @@ class _TravelPartnerPageState extends State<TravelPartnerPage>
try {
owned = await service.getUserCharacters(widget.userId);
} catch (e) {
return AppStrings.travelPartnerVerifyFailed('$e');
return AppStrings.travelPartnerVerifyFailed(describeApiError(e));
}
if (mounted) setState(() => _owned = owned);
@@ -311,14 +302,6 @@ class _TravelPartnerPageState extends State<TravelPartnerPage>
return AppStrings.travelPartnerNotSaved(missing.join(' / '));
}
void _updateStep(RiskStep step, [String? message]) {
if (!mounted) return;
setState(() {
_step = step;
_stepMessage = message ?? '';
});
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
@@ -327,211 +310,146 @@ class _TravelPartnerPageState extends State<TravelPartnerPage>
appBar: AppBar(
title: const Text(AppStrings.travelPartnerFeatureTitle),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: responsiveBody(
context,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (!isLoggedIn)
const AppNotice(
AppStrings.travelPartnerNotLoggedIn,
error: true,
),
if (isLoggedIn && cooldownRemaining > 0)
AppNotice(
AppStrings.travelPartnerCooldownNotice(cooldownRemaining),
),
_sectionCard(
theme,
icon: Icons.card_giftcard,
title: AppStrings.travelPartnerFeatureDesc,
),
const SizedBox(height: 12),
_fetchCard(theme),
const SizedBox(height: 12),
_grantCard(theme),
const SizedBox(height: 12),
_slotCard(theme),
const SizedBox(height: 12),
_ownedCard(theme),
const SizedBox(height: 12),
AutoLogoutToggle(
value: _autoLogout,
enabled: widget.onExitToTitle != null && !_running,
onChanged: (v) => setState(() => _autoLogout = v),
),
const SizedBox(height: 12),
_runButton(theme),
if (_step != RiskStep.idle) ...[
const SizedBox(height: 16),
StepProgressCard(
step: _step,
message: _stepMessage,
steps: [
(
RiskStep.fetchData,
AppStrings.travelPartnerStepFetch,
),
(
RiskStep.upload,
AppStrings.travelPartnerStepUpload,
),
if (_autoLogout) (RiskStep.logout, AppStrings.stepLogout),
],
),
body: RiskPageBody(
children: [
RiskStatusNotices(
isLoggedIn: isLoggedIn,
cooldownRemaining: cooldownRemaining,
notLoggedInText: AppStrings.travelPartnerNotLoggedIn,
cooldownText: AppStrings.travelPartnerCooldownNotice,
),
RiskFeatureCard(
icon: Icons.card_giftcard,
title: AppStrings.travelPartnerFeatureDesc,
),
const SizedBox(height: 12),
RiskFetchCard(
hasData: _userAllData.isNotEmpty && _owned.isNotEmpty,
statusText: _userAllData.isNotEmpty && _owned.isNotEmpty
? AppStrings.travelPartnerFetched
: AppStrings.travelPartnerNoData,
loadingLabel: AppStrings.travelPartnerFetching,
actionLabel: _userAllData.isNotEmpty
? AppStrings.travelPartnerRefetch
: AppStrings.travelPartnerFetchData,
loading: riskFetching,
enabled: !riskRunning,
onFetch: _fetchData,
),
const SizedBox(height: 12),
_grantCard(theme),
const SizedBox(height: 12),
_slotCard(theme),
const SizedBox(height: 12),
_ownedCard(theme),
const SizedBox(height: 12),
AutoLogoutToggle(
value: riskAutoLogout,
enabled: widget.onExitToTitle != null && !riskRunning,
onChanged: setRiskAutoLogout,
),
const SizedBox(height: 12),
RiskRunButton(
running: riskRunning,
enabled: isLoggedIn,
icon: Icons.send,
label: AppStrings.travelPartnerRun,
runningLabel: AppStrings.travelPartnerRunning,
onPressed: _run,
),
if (riskStep != RiskStep.idle) ...[
const SizedBox(height: 16),
StepProgressCard(
step: riskStep,
message: riskStepMessage,
error: riskError,
steps: [
(RiskStep.fetchData, AppStrings.travelPartnerStepFetch),
(RiskStep.upload, AppStrings.travelPartnerStepUpload),
if (riskAutoLogout) (RiskStep.logout, AppStrings.stepLogout),
],
],
),
),
),
);
}
Widget _sectionCard(
ThemeData theme, {
required IconData icon,
required String title,
List<Widget> children = const [],
}) {
return AppCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SectionTitle(icon: icon, title: title),
...children,
],
),
);
}
Widget _fetchCard(ThemeData theme) {
final hasData = _userAllData.isNotEmpty && _owned.isNotEmpty;
return AppCard(
child: 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.travelPartnerFetched
: AppStrings.travelPartnerNoData,
style: theme.textTheme.bodySmall?.copyWith(
color: hasData ? Colors.green : theme.colorScheme.onSurfaceVariant,
),
),
),
SizedBox(
height: 32,
child: OutlinedButton.icon(
onPressed: _loading || _running ? null : _fetchData,
icon: _loading
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.refresh, size: 16),
label: Text(
_loading
? AppStrings.travelPartnerFetching
: _userAllData.isNotEmpty
? AppStrings.travelPartnerRefetch
: AppStrings.travelPartnerFetchData,
style: theme.textTheme.labelSmall,
),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
),
],
],
),
);
}
Widget _grantCard(ThemeData theme) {
final enabled = !_running;
return _sectionCard(
theme,
final enabled = !riskRunning;
return RiskFeatureCard(
icon: Icons.add_circle_outline,
title: AppStrings.travelPartnerGrantTitle,
children: [
const SizedBox(height: 14),
TextField(
controller: _grantIdController,
enabled: enabled,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: AppStrings.travelPartnerCharacterIdLabel,
hintText: AppStrings.travelPartnerCharacterIdHint,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
),
contentPadding: const EdgeInsets.all(14),
),
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: enabled ? _addGrant : null,
icon: const Icon(Icons.add, size: 18),
label: const Text(AppStrings.listAddButton),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 14),
TextField(
controller: _grantIdController,
enabled: enabled,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: AppStrings.travelPartnerCharacterIdLabel,
hintText: AppStrings.travelPartnerCharacterIdHint,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
),
contentPadding: const EdgeInsets.all(14),
),
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: enabled ? _addGrant : null,
icon: const Icon(Icons.add, size: 18),
label: const Text(AppStrings.listAddButton),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
),
),
),
const SizedBox(height: 10),
Text(
_pendingGrant.isEmpty
? AppStrings.travelPartnerGrantEmpty
: _pendingGrant.map((e) => '#$e').join(' '),
style: theme.textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
color: _pendingGrant.isEmpty
? theme.colorScheme.onSurfaceVariant
: theme.colorScheme.onSurface,
),
),
const SizedBox(height: 12),
Text(
AppStrings.travelPartnerIdRangeTitle,
style: theme.textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
Text(
AppStrings.travelPartnerIdRangeBody,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
height: 1.5,
),
),
if (_pendingGrant.any(_isSuspectId)) ...[
const SizedBox(height: 8),
const SizedBox(height: 10),
Text(
AppStrings.travelPartnerIdUnknown,
_pendingGrant.isEmpty
? AppStrings.travelPartnerGrantEmpty
: _pendingGrant.map((e) => '#$e').join(' '),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error,
fontFamily: 'monospace',
color: _pendingGrant.isEmpty
? theme.colorScheme.onSurfaceVariant
: theme.colorScheme.onSurface,
),
),
const SizedBox(height: 12),
Text(
AppStrings.travelPartnerIdRangeTitle,
style: theme.textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
Text(
AppStrings.travelPartnerIdRangeBody,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
height: 1.5,
),
),
if (_pendingGrant.any(_isSuspectId)) ...[
const SizedBox(height: 8),
Text(
AppStrings.travelPartnerIdUnknown,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error,
),
),
],
],
],
),
);
}
@@ -558,60 +476,23 @@ class _TravelPartnerPageState extends State<TravelPartnerPage>
}.toList()
..sort();
return _sectionCard(
theme,
return RiskFeatureCard(
icon: Icons.groups_outlined,
title: AppStrings.travelPartnerSlotTitle,
children: [
const SizedBox(height: 14),
for (var i = 0; i < 5; i++)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
DropdownButtonFormField<int>(
initialValue: _slots[i],
decoration: InputDecoration(
labelText: AppStrings.travelPartnerSlotLabel(i),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 12,
),
),
items: [
DropdownMenuItem(
value: 0,
child: Text(
AppStrings.travelPartnerSlotNone,
style: theme.textTheme.bodyMedium,
),
),
for (final id in optionIds)
DropdownMenuItem(value: id, child: Text('#$id')),
],
onChanged: _running
? null
: (v) => setState(() {
final next = [..._slots];
next[i] = v ?? 0;
_slots = next;
}),
),
if (_slots[i] != 0) ...[
const SizedBox(height: 6),
TextField(
controller: _slotLevelControllers[i],
enabled: !_running,
keyboardType: TextInputType.number,
onChanged: (_) => setState(() {}),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 14),
for (var i = 0; i < 5; i++)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
DropdownButtonFormField<int>(
initialValue: _slots[i],
decoration: InputDecoration(
isDense: true,
labelText: AppStrings.travelPartnerLevelLabel,
hintText: AppStrings.travelPartnerLevelHint,
labelText: AppStrings.travelPartnerSlotLabel(i),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
),
@@ -620,27 +501,66 @@ class _TravelPartnerPageState extends State<TravelPartnerPage>
vertical: 12,
),
),
items: [
DropdownMenuItem(
value: 0,
child: Text(
AppStrings.travelPartnerSlotNone,
style: theme.textTheme.bodyMedium,
),
),
for (final id in optionIds)
DropdownMenuItem(value: id, child: Text('#$id')),
],
onChanged: riskRunning
? null
: (v) => setState(() {
final next = [..._slots];
next[i] = v ?? 0;
_slots = next;
}),
),
..._levelHintLines(theme, _slotLevelControllers[i].text),
if (_slots[i] != 0) ...[
const SizedBox(height: 6),
TextField(
controller: _slotLevelControllers[i],
enabled: !riskRunning,
keyboardType: TextInputType.number,
onChanged: (_) => setState(() {}),
decoration: InputDecoration(
isDense: true,
labelText: AppStrings.travelPartnerLevelLabel,
hintText: AppStrings.travelPartnerLevelHint,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 12,
),
),
),
..._levelHintLines(theme, _slotLevelControllers[i].text),
],
],
],
),
),
),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: _running ? null : _copySlot0ToOthers,
icon: const Icon(Icons.copy_all_outlined, size: 18),
label: const Text(AppStrings.travelPartnerCopySlot0),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: riskRunning ? null : _copySlot0ToOthers,
icon: const Icon(Icons.copy_all_outlined, size: 18),
label: const Text(AppStrings.travelPartnerCopySlot0),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
),
),
),
],
],
),
);
}
@@ -664,70 +584,46 @@ class _TravelPartnerPageState extends State<TravelPartnerPage>
}
Widget _ownedCard(ThemeData theme) {
return _sectionCard(
theme,
return RiskFeatureCard(
icon: Icons.workspace_premium_outlined,
title:
'${AppStrings.travelPartnerOwnedTitle} (${_owned.length})',
children: [
const SizedBox(height: 14),
if (_owned.isEmpty)
Text(
AppStrings.travelPartnerOwnedEmpty,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
)
else
Wrap(
spacing: 8,
runSpacing: 6,
children: [
for (final c in _owned)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 4,
),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest
.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(6),
),
child: Text(
'#${c.characterId} Lv${c.level}',
style: theme.textTheme.labelSmall?.copyWith(
fontFamily: 'monospace',
title: '${AppStrings.travelPartnerOwnedTitle} (${_owned.length})',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 14),
if (_owned.isEmpty)
Text(
AppStrings.travelPartnerOwnedEmpty,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
)
else
Wrap(
spacing: 8,
runSpacing: 6,
children: [
for (final c in _owned)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 4,
),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest
.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(6),
),
child: Text(
'#${c.characterId} Lv${c.level}',
style: theme.textTheme.labelSmall?.copyWith(
fontFamily: 'monospace',
),
),
),
),
],
),
],
);
}
Widget _runButton(ThemeData theme) {
return SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: _running || !isLoggedIn ? null : _run,
icon: _running
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.send, size: 20),
label: Text(
_running ? AppStrings.travelPartnerRunning : AppStrings.travelPartnerRun,
),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
],
),
],
),
);
}
+2 -2
View File
@@ -64,7 +64,7 @@ class AccountBackupService {
final value = await read();
sections[name] = value;
} catch (e) {
failed.add('$name: $e');
failed.add('$name: ${describeApiError(e)}');
}
}
@@ -103,7 +103,7 @@ class AccountBackupService {
try {
items['$kind'] = await _api.getUserItems(userId, kind);
} catch (e) {
failed.add('userItemList/$kind: $e');
failed.add('userItemList/$kind: ${describeApiError(e)}');
}
}(),
]);
+374
View File
@@ -0,0 +1,374 @@
import 'dart:convert';
import 'dart:ffi';
import 'dart:io';
import 'dart:isolate';
import 'dart:typed_data';
import 'package:ffi/ffi.dart';
import 'package:http/http.dart' as http;
import 'package:http/io_client.dart';
import 'api_log.dart';
/// aimedb 的 HTTPS 传输层。
///
/// 服务器按 TLS 指纹拦截:Dart/BoringSSL 的握手能被接受,但紧接着连接就被
/// 服务端关掉(Connection closed before full header was received),而系统栈
/// (WinHTTP / Schannel)能正常拿到响应。Windows 上因此改走 WinHTTP;其它平台
/// 仍用 HttpClient,并手动补上服务器没下发的中间证书。
class AimedbTransport {
AimedbTransport._();
/// 服务器只下发叶子证书,中间证书要自己补:Dart 的 BoringSSL 不会像浏览器
/// 那样顺着证书里的 AIA 地址去补链,缺了它就报 unable to get local issuer。
/// 这里仅把缺失的 GlobalSign 中间证书加进信任链,证书校验本身仍然开启。
static const String _globalSignIntermediatePem = '''
-----BEGIN CERTIFICATE-----
MIIFfjCCA2agAwIBAgIRAIRDWG9jliZDgTN8gBouYRgwDQYJKoZIhvcNAQELBQAw
RjELMAkGA1UEBhMCQkUxGTAXBgNVBAoTEEdsb2JhbFNpZ24gbnYtc2ExHDAaBgNV
BAMTE0dsb2JhbFNpZ24gUm9vdCBSNDYwHhcNMjUwOTE3MDI1NTMwWhcNMjkwNjIz
MDAwMDAwWjBWMQswCQYDVQQGEwJCRTEZMBcGA1UEChMQR2xvYmFsU2lnbiBudi1z
YTEsMCoGA1UEAxMjR2xvYmFsU2lnbiBHQ0MgUjQ2IEFscGhhU1NMIENBIDIwMjUw
ggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCV0s/1NfwruxzhrfoOWN/B
V8j/6KxInyuIpVJ50pmHimyU5ACjC4ST2ZyU2Ltggfc/ydc1OrTUqDyoTGjWOazH
obK+GQQtBd+MUEykZbnVCfNj14Um7UiqTGOFaOK551Etd9aE/F9/hYl+4XMXJPqC
+V3ziJbO0QQcL4/wZBvqbW3xZjqb/bcLKHttfNF+JMmO+DrT9VWyomNWVeC3Grs4
DybZLgs4RVmg8sp/Ic0quOdBjIhE+W0jYbUW6Z9HP9q2lh7UVCwn1rZ1mFTRhIXa
shGqL43uXnXfRls3T93w2dnCpgTundq28WmWuzb/RZ6kHvALQmu8f891gHfbb+kb
AgMBAAGjggFVMIIBUTAOBgNVHQ8BAf8EBAMCAYYwEwYDVR0lBAwwCgYIKwYBBQUH
AwEwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQUo0yssb+0iO2LdNhfNDjJ
6QolnnswHwYDVR0jBBgwFoAUA1yrc4GHqMywptWU4jaWSf8FmSwwewYIKwYBBQUH
AQEEbzBtMC4GCCsGAQUFBzABhiJodHRwOi8vb2NzcC5nbG9iYWxzaWduLmNvbS9y
b290cjQ2MDsGCCsGAQUFBzAChi9odHRwOi8vc2VjdXJlLmdsb2JhbHNpZ24uY29t
L2NhY2VydC9yb290cjQ2LmNydDA2BgNVHR8ELzAtMCugKaAnhiVodHRwOi8vY3Js
Lmdsb2JhbHNpZ24uY29tL3Jvb3RyNDYuY3JsMCEGA1UdIAQaMBgwCAYGZ4EMAQIB
MAwGCisGAQQBoDIKAQMwDQYJKoZIhvcNAQELBQADggIBAFidiFkGjaslf0o1kWbY
Y1Fe0N/OtR28cj5js0b6mhb0AXgyi8m3IOBBnHFsyGb/OGpGlsfnyOCNHNc4p12Z
f8tqkqtd1qh2oks7+MvEAatwDy4NMlQYmjRpdTzTu6+HFv3waK+UOHbm1NC5s5fb
lPjio082KdjQsG+isWSCUGP7hjVjTcPioy5v0HJDYzmbX1oro7fa7potZ4vjNPRI
mMH2St+E2OphOO4NkrllXtSUw5ThyiFaymFIvWfSXSWOHIcK3HwOlUxgpgrJMDi0
ZuKX3W2+wDVRmrPJXgaX+6R/uBqtdMi2O+ebkjmS5zyk2U7sHsaa9lPQz3PS5hBv
aeW0FHeJK4Yc2yeQ/HBRL3YORG5JQdH1+P/+OJnv7s10Qjipe0tPwHccfMSprzRs
0t/2wG3b2GdTBX9JJjxWp3SJs/Bib7ScMJYyMMgrUBQ/BSCreEpvKvrsw2SAsPwY
dx7fjCpsFBM0Tdrqzc1HUm/qNgETPA6tWTMn+27ot19Q94KpnEHYL1hRyCJ5JB/6
OWVNCbn2YhtwJON6787ZbkVHOz9itAZKajPNH/nO/wB4gtlhnQb1yhZ6nG6LZAsu
gJBL58/BSqH1KWGnHyp9s7VwFJPI3LoSEqLd5BryAOQg/5P5uW339YFmbJCcqS3G
3CVvDrQnx+qrRlxBrS/QeigQ
-----END CERTIFICATE-----
''';
static Future<http.Response> post(
Uri uri, {
required Map<String, String> headers,
required List<int> body,
}) async {
if (Platform.isWindows) {
ApiLog.log('[aimedb] transport=WinHTTP');
return _winHttpPost(uri, headers, body);
}
ApiLog.log('[aimedb] transport=Dart HttpClient');
return _dartPost(uri, headers, body);
}
static Future<http.Response> _dartPost(
Uri uri,
Map<String, String> headers,
List<int> body,
) async {
final context = SecurityContext(withTrustedRoots: true)
..setTrustedCertificatesBytes(utf8.encode(_globalSignIntermediatePem));
final client = IOClient(HttpClient(context: context));
try {
final request = http.Request('POST', uri)
..headers.addAll(headers)
..bodyBytes = body;
return await http.Response.fromStream(await client.send(request));
} finally {
client.close();
}
}
/// FFI 调用是同步阻塞的,放到后台 isolate 里跑,避免卡住登录页。
static Future<http.Response> _winHttpPost(
Uri uri,
Map<String, String> headers,
List<int> body,
) async {
final result = await Isolate.run(
() => _winHttpRequest(
host: uri.host,
path: uri.path,
headers: headers,
body: Uint8List.fromList(body),
),
);
if (result.error != null) {
throw HttpException('WinHTTP 请求失败:${result.error}', uri: uri);
}
return http.Response.bytes(
result.body,
result.statusCode,
request: http.Request('POST', uri),
);
}
}
class _WinHttpResult {
final int statusCode;
final Uint8List body;
final String? error;
const _WinHttpResult(this.statusCode, this.body, [this.error]);
}
const int _winHttpAccessAutomaticProxy = 4;
const int _winHttpFlagSecure = 0x00800000;
const int _winHttpQueryStatusCode = 19;
const int _winHttpQueryFlagNumber = 0x20000000;
typedef _OpenNative = IntPtr Function(
Pointer<Utf16>,
Uint32,
Pointer<Utf16>,
Pointer<Utf16>,
Uint32,
);
typedef _OpenDart = int Function(
Pointer<Utf16>,
int,
Pointer<Utf16>,
Pointer<Utf16>,
int,
);
typedef _ConnectNative = IntPtr Function(
IntPtr,
Pointer<Utf16>,
Uint16,
Uint32,
);
typedef _ConnectDart = int Function(int, Pointer<Utf16>, int, int);
typedef _OpenRequestNative = IntPtr Function(
IntPtr,
Pointer<Utf16>,
Pointer<Utf16>,
Pointer<Utf16>,
Pointer<Utf16>,
Pointer<IntPtr>,
Uint32,
);
typedef _OpenRequestDart = int Function(
int,
Pointer<Utf16>,
Pointer<Utf16>,
Pointer<Utf16>,
Pointer<Utf16>,
Pointer<IntPtr>,
int,
);
typedef _SetTimeoutsNative = Int32 Function(IntPtr, Int32, Int32, Int32, Int32);
typedef _SetTimeoutsDart = int Function(int, int, int, int, int);
typedef _SendNative = Int32 Function(
IntPtr,
Pointer<Utf16>,
Uint32,
Pointer<Uint8>,
Uint32,
Uint32,
IntPtr,
);
typedef _SendDart = int Function(
int,
Pointer<Utf16>,
int,
Pointer<Uint8>,
int,
int,
int,
);
typedef _ReceiveNative = Int32 Function(IntPtr, Pointer<Void>);
typedef _ReceiveDart = int Function(int, Pointer<Void>);
typedef _QueryHeadersNative = Int32 Function(
IntPtr,
Uint32,
Pointer<Utf16>,
Pointer<Void>,
Pointer<Uint32>,
Pointer<Uint32>,
);
typedef _QueryHeadersDart = int Function(
int,
int,
Pointer<Utf16>,
Pointer<Void>,
Pointer<Uint32>,
Pointer<Uint32>,
);
typedef _ReadNative = Int32 Function(
IntPtr,
Pointer<Uint8>,
Uint32,
Pointer<Uint32>,
);
typedef _ReadDart = int Function(int, Pointer<Uint8>, int, Pointer<Uint32>);
typedef _CloseNative = Int32 Function(IntPtr);
typedef _CloseDart = int Function(int);
typedef _LastErrorNative = Uint32 Function();
typedef _LastErrorDart = int Function();
class _WinHttp {
static final DynamicLibrary _winhttp = DynamicLibrary.open('winhttp.dll');
static final DynamicLibrary _kernel32 = DynamicLibrary.open('kernel32.dll');
static final _OpenDart open = _winhttp
.lookupFunction<_OpenNative, _OpenDart>('WinHttpOpen');
static final _ConnectDart connect = _winhttp
.lookupFunction<_ConnectNative, _ConnectDart>('WinHttpConnect');
static final _OpenRequestDart openRequest = _winhttp
.lookupFunction<_OpenRequestNative, _OpenRequestDart>(
'WinHttpOpenRequest',
);
static final _SetTimeoutsDart setTimeouts = _winhttp
.lookupFunction<_SetTimeoutsNative, _SetTimeoutsDart>(
'WinHttpSetTimeouts',
);
static final _SendDart send = _winhttp
.lookupFunction<_SendNative, _SendDart>('WinHttpSendRequest');
static final _ReceiveDart receive = _winhttp
.lookupFunction<_ReceiveNative, _ReceiveDart>('WinHttpReceiveResponse');
static final _QueryHeadersDart queryHeaders = _winhttp
.lookupFunction<_QueryHeadersNative, _QueryHeadersDart>(
'WinHttpQueryHeaders',
);
static final _ReadDart read = _winhttp
.lookupFunction<_ReadNative, _ReadDart>('WinHttpReadData');
static final _CloseDart close = _winhttp
.lookupFunction<_CloseNative, _CloseDart>('WinHttpCloseHandle');
static final _LastErrorDart lastError = _kernel32
.lookupFunction<_LastErrorNative, _LastErrorDart>('GetLastError');
}
/// 同步版本,只能在后台 isolate 里调用。
_WinHttpResult _winHttpRequest({
required String host,
required String path,
required Map<String, String> headers,
required Uint8List body,
}) {
final headerText = headers.entries
.map((e) => '${e.key}: ${e.value}')
.join('\r\n');
final agent = 'Ichino/1.0'.toNativeUtf16();
final hostPtr = host.toNativeUtf16();
final verb = 'POST'.toNativeUtf16();
final pathPtr = path.toNativeUtf16();
final version = 'HTTP/1.1'.toNativeUtf16();
final headersPtr = '$headerText\r\n'.toNativeUtf16();
final bodyPtr = calloc<Uint8>(body.isEmpty ? 1 : body.length);
if (body.isNotEmpty) {
bodyPtr.asTypedList(body.length).setAll(0, body);
}
final statusPtr = calloc<Uint32>();
final statusLen = calloc<Uint32>()..value = 4;
final readBuf = calloc<Uint8>(16384);
final readLen = calloc<Uint32>();
var session = 0;
var connect = 0;
var request = 0;
try {
session = _WinHttp.open(
agent,
_winHttpAccessAutomaticProxy,
nullptr,
nullptr,
0,
);
if (session == 0) return _error('WinHttpOpen');
// 同步调用本身没有超时,交给 WinHTTP 内部的四段超时兜底。
_WinHttp.setTimeouts(session, 8000, 12000, 20000, 20000);
connect = _WinHttp.connect(session, hostPtr, 443, 0);
if (connect == 0) return _error('WinHttpConnect');
request = _WinHttp.openRequest(
connect,
verb,
pathPtr,
version,
nullptr,
nullptr,
_winHttpFlagSecure,
);
if (request == 0) return _error('WinHttpOpenRequest');
final sent = _WinHttp.send(
request,
headersPtr,
0xFFFFFFFF,
bodyPtr,
body.length,
body.length,
0,
);
if (sent == 0) return _error('WinHttpSendRequest');
if (_WinHttp.receive(request, nullptr) == 0) {
return _error('WinHttpReceiveResponse');
}
final statusOk = _WinHttp.queryHeaders(
request,
_winHttpQueryStatusCode | _winHttpQueryFlagNumber,
nullptr,
statusPtr.cast(),
statusLen,
nullptr,
);
final statusCode = statusOk == 0 ? 0 : statusPtr.value;
// 必须让 BytesBuilder 复制:readBuf 会在下一轮读取时被覆盖。
final chunks = BytesBuilder();
while (true) {
if (_WinHttp.read(request, readBuf, 16384, readLen) == 0) {
return _error('WinHttpReadData');
}
final n = readLen.value;
if (n == 0) break;
chunks.add(readBuf.asTypedList(n));
if (chunks.length > 4 * 1024 * 1024) break;
}
return _WinHttpResult(statusCode, chunks.takeBytes());
} finally {
if (request != 0) _WinHttp.close(request);
if (connect != 0) _WinHttp.close(connect);
if (session != 0) _WinHttp.close(session);
calloc.free(agent);
calloc.free(hostPtr);
calloc.free(verb);
calloc.free(pathPtr);
calloc.free(version);
calloc.free(headersPtr);
calloc.free(bodyPtr);
calloc.free(statusPtr);
calloc.free(statusLen);
calloc.free(readBuf);
calloc.free(readLen);
}
}
_WinHttpResult _error(String what) {
final code = _WinHttp.lastError();
return _WinHttpResult(0, Uint8List(0), '$what(GetLastError=$code)');
}
+139 -58
View File
@@ -1,9 +1,9 @@
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:http/http.dart' as http;
import '../config/title_server_config.dart';
import 'aimedb_transport.dart';
import 'api_log.dart';
class LoginResult {
@@ -12,90 +12,171 @@ class LoginResult {
final int userId;
final String token;
/// 服务器拒绝登录时给用户的说明(含 errorID 的含义)。
final String? message;
const LoginResult({
required this.success,
required this.errorId,
required this.userId,
required this.token,
this.message,
});
}
/// aimedb 扫码登录:二维码串(或二维码链接)→ userID + token。
///
/// 与 Astral `api/Chime.py` 保持一致:
/// * 签名 key = SHA-256(完整 Keychip + 本机本地时间 + 签名盐),大写 hex;
/// 拼接里不含 qrCode,也不含 titlekey。
/// * `qrCode` 字段发 84 串第 21 位起的 64 位载荷,不是整串。
/// * 请求头**不能带 Content-Type**,带了服务器直接回 errorID=57。
/// * chipID 用完整 Keychip(带连字符);游戏服务器那边才用 11 位短值。
class ApiService {
static const String _aimeHost = 'ai.sys-allnet.cn';
static const String _aimePath = '/wc_aime/api/get_data';
static const String _signKey = 'XcW5FW4cPArBXEk4vzKz3CIrMuA5EVVW';
static const String _openGameId = 'MAID';
static const String _titleKey = 'SDGB';
static const String _userAgent = 'WC_AIME_LIB';
static const String _qrPrefix = 'SGWC';
static const String _qrGameId = 'MAID';
/// SGWC(4) + MAID(4) + 时间戳(12)
static const int _qrHeadLength = 20;
static const int _qrTotalLength = 84;
static final Uri _aimeUri = Uri.https(_aimeHost, _aimePath);
final TitleServerConfig? _config;
ApiService([this._config]);
TitleServerConfig? get _cfg => _config ?? TitleServerConfigHolder().config;
String get _chipId => _cfg?.keychipId ?? '';
String get _aimeSalt => _cfg?.aimeSalt ?? '';
String get _aimeUrl => _cfg?.aimeUrl ?? '';
String get _openGameID {
final v = _cfg?.openGameID;
return (v != null && v.isNotEmpty) ? v : 'MAID';
}
String _formatTimestamp() {
final tokyo = DateTime.now().toUtc().add(const Duration(hours: 9));
final y = (tokyo.year % 100).toString().padLeft(2, '0');
final M = tokyo.month.toString().padLeft(2, '0');
final d = tokyo.day.toString().padLeft(2, '0');
final h = tokyo.hour.toString().padLeft(2, '0');
final m = tokyo.minute.toString().padLeft(2, '0');
final s = tokyo.second.toString().padLeft(2, '0');
return '$y$M$d$h$m$s';
}
/// 任意输入 -> 标准 84 字符二维码串,格式不合预期时抛 [FormatException]。
static String normalizeQr(String input) {
var qr = input.trim();
String _sha256(String input) {
final bytes = utf8.encode(input);
return sha256.convert(bytes).toString();
}
static String extractQRCode(String qrCodeToken) {
if (qrCodeToken.length > 64) {
return qrCodeToken.substring(qrCodeToken.length - 64);
if (qr.startsWith('http://') || qr.startsWith('https://')) {
final path = Uri.parse(qr).path;
final match = RegExp(r'/qrcode/req/([^/]+)\.html').firstMatch(path);
if (match == null) {
throw FormatException('这个链接里取不出二维码数据:$qr');
}
qr = _qrPrefix + match.group(1)!;
}
return qrCodeToken;
if (qr.length != _qrTotalLength) {
throw FormatException(
'二维码长度应为 $_qrTotalLength 字符,实际 ${qr.length} 字符',
);
}
if (!qr.startsWith(_qrPrefix)) {
throw FormatException('二维码应当以 "$_qrPrefix" 开头');
}
if (qr.substring(4, 8) != _qrGameId) {
throw FormatException('二维码里的 GameID 应当是 "$_qrGameId"');
}
final payload = qr.substring(_qrHeadLength);
if (!RegExp(r'^[0-9A-F]+$').hasMatch(payload)) {
throw FormatException('二维码载荷必须是大写十六进制');
}
return qr;
}
Future<LoginResult> login(String qrCodeToken) async {
final timestamp = _formatTimestamp();
final qrCode = extractQRCode(qrCodeToken);
final chipId = _chipId;
final rawKey = chipId + timestamp + _aimeSalt;
final key = _sha256(rawKey).toUpperCase();
/// 请求体 `qrCode` 字段的值:84 串去掉前 20 位后的 64 位载荷。
static String extractQRCode(String input) =>
normalizeQr(input).substring(_qrHeadLength);
final body = jsonEncode({
'chipID': chipId,
'openGameID': _openGameID,
'key': key,
'qrCode': qrCode,
'timestamp': timestamp,
});
/// 真机报的是机台本地墙上时间,所以这里跟着设备时区走,不固定偏移。
static String formatTimestamp(DateTime now) {
String two(int v) => v.toString().padLeft(2, '0');
return '${two(now.year % 100)}${two(now.month)}${two(now.day)}'
'${two(now.hour)}${two(now.minute)}${two(now.second)}';
}
ApiLog.log('[login] POST $_aimeUrl');
ApiLog.log('[login] chipId=$chipId openGameID=$_openGameID');
Future<LoginResult> login(String qrInput) async {
final chipId = _cfg?.fullKeychipId ?? '';
if (chipId.isEmpty) {
throw const FormatException('设置里没有 Keychip,无法生成签名');
}
final response = await http
.post(
Uri.parse(_aimeUrl),
headers: {
'Content-Type': 'application/json',
'User-Agent': 'WC_AIME_LIB',
},
body: body,
)
.timeout(const Duration(seconds: 15));
final qr = normalizeQr(qrInput);
final timestamp = formatTimestamp(DateTime.now());
final key = sha256
.convert(utf8.encode(chipId + timestamp + _signKey))
.toString()
.toUpperCase();
final obj = jsonDecode(response.body) as Map<String, dynamic>;
final errorId = obj['errorID'] as int? ?? -1;
final userId = obj['userID'] as int? ?? -1;
final bodyBytes = utf8.encode(
jsonEncode({
'chipID': chipId,
'openGameID': _openGameId,
'key': key,
'qrCode': qr.substring(_qrHeadLength),
'timestamp': timestamp,
'titlekey': _titleKey,
}),
);
ApiLog.log('[login] POST https://$_aimeHost$_aimePath');
ApiLog.log('[login] chipID=$chipId timestamp=$timestamp');
// 不带 Content-Type:aimedb 见到这个头会回 errorID=57。
// 传输层在 Windows 上走 WinHTTP,绕过服务器对 BoringSSL 指纹的拦截。
final response = await AimedbTransport.post(
_aimeUri,
headers: const {'Connection': 'Keep-Alive', 'User-Agent': _userAgent},
body: bodyBytes,
).timeout(const Duration(seconds: 25));
ApiLog.log(
'[login] HTTP ${response.statusCode}, ${response.bodyBytes.length} bytes',
);
final Map<String, dynamic> obj;
try {
obj = jsonDecode(response.body) as Map<String, dynamic>;
} on Exception {
final preview = response.body.trim();
throw FormatException(
'aimedb 的响应不是合法 JSON:'
'${preview.length > 200 ? preview.substring(0, 200) : preview}',
);
}
final errorId = (obj['errorID'] as num?)?.toInt() ?? -1;
final userId = (obj['userID'] as num?)?.toInt() ?? -1;
final token = obj['token'] as String? ?? '';
// Session cookies (JSESSIONID) are captured from the title server's
// UserLoginApi response, not from the Aime server — see empurple.
if (errorId != 0) {
return LoginResult(
success: false,
errorId: errorId,
userId: userId,
token: token,
message: errorId == 57
? '登录被拒(errorID=57):二维码已失效或已被使用'
: '登录被拒(errorID=$errorId)',
);
}
if (userId == -1 || token.isEmpty) {
return LoginResult(
success: false,
errorId: errorId,
userId: userId,
token: token,
message: '扫码登录没有拿到 userID/token'
'(userID=$userId,token 长度 ${token.length})',
);
}
final tokenHead = token.length < 8 ? token : token.substring(0, 8);
ApiLog.log('[login] 成功 userID=$userId token=$tokenHead...');
return LoginResult(
success: errorId == 0,
success: true,
errorId: errorId,
userId: userId,
token: token,
+4 -17
View File
@@ -2,6 +2,7 @@ import 'dart:convert';
import 'package:http/http.dart' as http;
import '../core/json_value.dart';
import 'music_data_cache.dart';
class MusicInfo {
@@ -62,32 +63,18 @@ class MusicDataService {
if (decoded is! List) return;
_byId.clear();
for (final item in decoded.whereType<Map<String, dynamic>>()) {
final id = _toInt(item['id']);
final id = jsonInt(item['id']);
if (id == 0) continue;
final ds = item['ds'];
_byId[id] = MusicInfo(
title: item['title']?.toString() ?? 'Unknown',
title: jsonString(item['title'], fallback: 'Unknown'),
dsByLevel: ds is List
? {
for (var level = 0; level < ds.length; level++)
level: _toDouble(ds[level]),
level: jsonDouble(ds[level]),
}
: const {},
);
}
}
static int _toInt(dynamic v) {
if (v is int) return v;
if (v is num) return v.toInt();
if (v is String) return int.tryParse(v) ?? 0;
return 0;
}
static double _toDouble(dynamic v) {
if (v is double) return v;
if (v is int) return v.toDouble();
if (v is String) return double.tryParse(v) ?? 0.0;
return 0.0;
}
}
+32 -15
View File
@@ -8,6 +8,7 @@ import 'package:http/http.dart' as http;
import 'package:pointycastle/export.dart';
import '../config/title_server_config.dart';
import '../core/date_time_format.dart';
import '../models/user_data.dart';
import 'api_log.dart';
import '../models/user_character.dart';
@@ -22,6 +23,12 @@ class TitleApiException implements Exception {
String toString() => 'TitleApiException: $message';
}
/// 面向用户的错误文本:协议层异常只取 message,其余保留 `toString()` 便于排查。
String describeApiError(Object error) =>
error is TitleApiException ? error.message : error.toString();
typedef CookiesChangedCallback = void Function(String cookies);
class UserLoginResult {
final String token;
final int loginId;
@@ -39,21 +46,37 @@ class UserLoginResult {
class TitleApiService {
static const String _obfuscateConstant = 'MaimaiChn';
static String? lastRawResponse;
/// 最近一次 `captureRaw` 请求的响应原文(主页调试卡用)。
String? lastRawResponse;
final TitleServerConfig _config;
final CookiesChangedCallback? onCookiesChanged;
String? _cookies;
TitleApiService(this._config, {this._cookies});
// 私有命名参数不合法(不能写 `this._cookies`),只能手写初始化。
TitleApiService(
this._config, {
String? cookies,
this.onCookiesChanged,
}) :
// ignore: prefer_initializing_formals
_cookies = cookies;
/// 从 [TitleServerConfigHolder] 构造;未配置时返回 `null`。
///
/// 各页原本都写 `if (!holder.isConfigured) ...; TitleApiService(holder.config!, ...)`,
/// 收口成一个工厂,避免 `config!` 与 `isConfigured` 散落在十几个地方。
static TitleApiService? fromHolder({String? cookies}) {
static TitleApiService? fromHolder({
String? cookies,
CookiesChangedCallback? onCookiesChanged,
}) {
final holder = TitleServerConfigHolder();
if (!holder.isConfigured) return null;
return TitleApiService(holder.config!, cookies: cookies);
return TitleApiService(
holder.config!,
cookies: cookies,
onCookiesChanged: onCookiesChanged,
);
}
String? get cookies => _cookies;
@@ -122,6 +145,7 @@ class TitleApiService {
if (match != null) {
_cookies = 'JSESSIONID=${match.group(1)!.trim()}';
ApiLog.log('[$apiName] >>> Captured session cookie: $_cookies');
onCookiesChanged?.call(_cookies!);
return;
}
}
@@ -243,8 +267,8 @@ class TitleApiService {
required int userId,
required String token,
}) async {
// 走通用传输层,保留 `lastRawResponse`(主页调试卡要用 GetUserPreviewApi
// 的原文,而不是随后 UserLoginApi / GetUserDataApi 的)。
// 走通用传输层,把 GetUserPreviewApi 的原文留在实例上(主页调试卡要用它,
// 而不是随后 UserLoginApi / GetUserDataApi 的响应)。
final json = await _callApi(
'GetUserPreviewApi',
{
@@ -386,10 +410,8 @@ class TitleApiService {
final now = DateTime.now();
final validDate = DateTime(now.year, now.month, now.day, 4, 0, 0)
.add(const Duration(days: 90));
final purchaseDateStr = _formatDateTime(now);
final validDateStr =
'${validDate.year}-${validDate.month.toString().padLeft(2, '0')}-${validDate.day.toString().padLeft(2, '0')} '
'${validDate.hour.toString().padLeft(2, '0')}:${validDate.minute.toString().padLeft(2, '0')}:${validDate.second.toString().padLeft(2, '0')}';
final purchaseDateStr = formatPlayDateTime(now);
final validDateStr = formatDateTime(validDate);
final packet = {
'userId': userId,
@@ -416,11 +438,6 @@ class TitleApiService {
_checkReturnCode(json, apiName);
}
String _formatDateTime(DateTime dt) {
return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')} '
'${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}:${dt.second.toString().padLeft(2, '0')}.0';
}
// ---- UpsertUserAll (发包) ----
/// Upload a single playlog record (fake game log).
+8 -23
View File
@@ -1,4 +1,6 @@
import '../config/title_server_config.dart';
import '../core/date_time_format.dart';
import '../core/json_value.dart';
import '../models/user_character.dart';
import 'title_api_service.dart';
@@ -43,28 +45,12 @@ class UserAllPayloadBuilder {
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';
/// 客户端 `TimeManager.GetDateString` 的格式是
/// `ToString("yyyy-MM-dd HH:mm:ss.f")`,也就是带一位小数的秒。
/// 万花筒的 `clearDate` 之类字段要按这个形状给。
static String nowTimestamp() => _formatPlayDateTime(_shanghaiNow());
static String nowTimestamp() => formatPlayDateTime(shanghaiNow());
static int _toInt(dynamic v) {
if (v is int) return v;
if (v is num) return v.toInt();
return 0;
}
static int _toInt(dynamic v) => jsonInt(v);
static int clampValue(int v) =>
v < minValue ? minValue : (v > maxValue ? maxValue : v);
@@ -122,11 +108,10 @@ class UserAllPayloadBuilder {
(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)));
final now = shanghaiNow();
final playDate = formatDate(now);
final playDateTime = formatPlayDateTime(now);
final loginDate = formatPlayDateTime(shanghaiFromUnixSeconds(loginDateTime));
// Lionheart: GetUserDataApi 返回的部分字段不能出现在 UpsertUserAll 的
// userData 中,否则服务器会静默丢弃整包处理。
+5 -2
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show Clipboard, ClipboardData;
import '../config/strings.dart';
import '../models/session_model.dart';
import '../services/account_backup_service.dart';
import '../services/title_api_service.dart';
import 'app_card.dart';
@@ -28,7 +29,9 @@ class _AccountBackupCardState extends State<AccountBackupCard> {
String? _error;
Future<void> _run() async {
final service = TitleApiService.fromHolder(cookies: widget.cookies);
final service = SessionModel.instance.createTitleService(
cookies: widget.cookies,
);
if (service == null) {
context.showSnack(AppStrings.ticketNotConfigured);
return;
@@ -56,7 +59,7 @@ class _AccountBackupCardState extends State<AccountBackupCard> {
} catch (e) {
if (!mounted) return;
setState(() {
_error = '$e';
_error = describeApiError(e);
_running = false;
});
}
+11 -1
View File
@@ -11,6 +11,7 @@ class AppCard extends StatelessWidget {
final EdgeInsetsGeometry padding;
final Color? borderColor;
final EdgeInsetsGeometry? margin;
final VoidCallback? onTap;
const AppCard({
super.key,
@@ -18,11 +19,14 @@ class AppCard extends StatelessWidget {
this.padding = const EdgeInsets.all(20),
this.borderColor,
this.margin,
this.onTap,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final content = Padding(padding: padding, child: child);
return Card(
elevation: 0,
margin: margin,
@@ -33,7 +37,13 @@ class AppCard extends StatelessWidget {
theme.colorScheme.outline.withValues(alpha: 0.3),
),
),
child: Padding(padding: padding, child: child),
child: onTap == null
? content
: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: onTap,
child: content,
),
);
}
}
+243
View File
@@ -0,0 +1,243 @@
import 'package:flutter/material.dart';
import '../../config/strings.dart';
import '../app_card.dart';
/// 主页头像:iconId 为 0 时显示占位人形。
class HomeAvatar extends StatelessWidget {
final int iconId;
const HomeAvatar({super.key, required this.iconId});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return CircleAvatar(
radius: 30,
backgroundColor: theme.colorScheme.primaryContainer,
backgroundImage: iconId > 0
? NetworkImage('https://assets2.lxns.net/maimai/icon/$iconId.png')
: null,
child: iconId == 0
? Icon(Icons.person, color: theme.colorScheme.onPrimaryContainer)
: null,
);
}
}
/// 主页顶部的账号概要卡,Preview 与完整数据共用。
///
/// [highestRating] 为 null 时不显示「历史最高」一行(Preview 数据里没有)。
class HomeProfileCard extends StatelessWidget {
final String userName;
final int iconId;
final int playerRating;
final String? highestRating;
const HomeProfileCard({
super.key,
required this.userName,
required this.iconId,
required this.playerRating,
this.highestRating,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return AppCard(
child: Row(
children: [
HomeAvatar(iconId: iconId),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
userName.isEmpty ? AppStrings.unknownUser : userName,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
AppStrings.rating(playerRating),
style: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
if (highestRating != null) ...[
const SizedBox(height: 2),
Text(
'${AppStrings.highestRating}: $highestRating',
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
],
),
),
],
),
);
}
}
/// 主页的「标题 + 字段行」信息卡。
class HomeSectionCard extends StatelessWidget {
final String title;
final List<(String, String)> rows;
const HomeSectionCard({
super.key,
required this.title,
required this.rows,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return AppCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 14),
for (final (label, value) in rows)
_InfoRow(label: label, value: value),
],
),
);
}
}
class _InfoRow extends StatelessWidget {
final String label;
final String value;
const _InfoRow({required this.label, required this.value});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 110,
child: Text(
label,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
Expanded(
child: Text(
value.isEmpty ? '-' : value,
style: theme.textTheme.bodyMedium,
),
),
],
),
);
}
}
/// 主页状态卡:标题 + 若干 [HomeStatusChip]。
class HomeStatusCard extends StatelessWidget {
final List<Widget> chips;
const HomeStatusCard({super.key, required this.chips});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return AppCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
AppStrings.status,
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 14),
...chips,
],
),
);
}
}
/// 状态行:bool 按成功/失败色渲染成胶囊,其他值原样显示。
class HomeStatusChip extends StatelessWidget {
final String label;
final Object? value;
final String trueText;
final String falseText;
const HomeStatusChip(
this.label,
this.value, {
super.key,
this.trueText = 'Yes',
this.falseText = 'No',
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final v = value;
final String text;
final Color color;
if (v is bool) {
text = v ? trueText : falseText;
color = v ? Colors.green : Colors.grey;
} else {
text = '$v';
color = theme.colorScheme.onSurface;
}
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
SizedBox(
width: 110,
child: Text(
label,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 2),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(6),
),
child: Text(
text,
style: theme.textTheme.labelSmall?.copyWith(color: color),
),
),
],
),
);
}
}
+111
View File
@@ -0,0 +1,111 @@
import 'package:flutter/material.dart';
import '../../config/strings.dart';
import '../app_card.dart';
/// 调试用的原始 API 响应卡,默认折叠。
class DebugRawJsonCard extends StatefulWidget {
final String rawJson;
const DebugRawJsonCard({super.key, required this.rawJson});
@override
State<DebugRawJsonCard> createState() => _DebugRawJsonCardState();
}
class _DebugRawJsonCardState extends State<DebugRawJsonCard> {
bool _expanded = false;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return AppCard(
borderColor: Colors.orange.withValues(alpha: 0.5),
onTap: () => setState(() => _expanded = !_expanded),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.bug_report, size: 16, color: Colors.orange),
const SizedBox(width: 8),
Expanded(
child: Text(
AppStrings.rawApiResponse,
style: theme.textTheme.labelLarge?.copyWith(
color: Colors.orange,
fontWeight: FontWeight.w600,
),
),
),
Icon(
_expanded ? Icons.expand_less : Icons.expand_more,
color: Colors.orange,
),
],
),
if (_expanded) ...[
const SizedBox(height: 12),
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest.withValues(
alpha: 0.5,
),
borderRadius: BorderRadius.circular(8),
),
child: SelectableText(
widget.rawJson,
style: theme.textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
fontSize: 11,
),
),
),
],
],
),
);
}
}
class ErrorIdBanner extends StatelessWidget {
final int errorId;
const ErrorIdBanner({super.key, required this.errorId});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
width: double.infinity,
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: theme.colorScheme.errorContainer,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(
Icons.warning_amber_rounded,
size: 20,
color: theme.colorScheme.onErrorContainer,
),
const SizedBox(width: 10),
Expanded(
child: Text(
'API returned errorId: $errorId',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onErrorContainer,
fontWeight: FontWeight.w600,
),
),
),
],
),
);
}
}
+47
View File
@@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
import '../../config/strings.dart';
import '../../models/user_preview.dart';
import 'home_common.dart';
class PreviewGameInfoCard extends StatelessWidget {
final UserPreviewDataBean preview;
const PreviewGameInfoCard({super.key, required this.preview});
@override
Widget build(BuildContext context) {
return HomeSectionCard(
title: AppStrings.gameInfo,
rows: [
(AppStrings.lastGame, preview.lastGameId),
(AppStrings.lastPlay, preview.lastPlayDate),
(AppStrings.lastLogin, preview.lastLoginDate),
(AppStrings.romVersion, preview.lastRomVersion),
(AppStrings.dataVersion, preview.lastDataVersion),
],
);
}
}
class PreviewStatusCard extends StatelessWidget {
final UserPreviewDataBean preview;
const PreviewStatusCard({super.key, required this.preview});
@override
Widget build(BuildContext context) {
return HomeStatusCard(
chips: [
HomeStatusChip(AppStrings.netMember, preview.isNetMember),
HomeStatusChip(AppStrings.inherit, preview.isInherit),
HomeStatusChip(
AppStrings.banState,
preview.banState == 0,
trueText: AppStrings.clean,
falseText: '已封禁 (${preview.banState})',
),
],
);
}
}
+209
View File
@@ -0,0 +1,209 @@
import 'package:flutter/material.dart';
import '../../config/strings.dart';
import '../../models/user_data.dart';
import '../../models/user_preview.dart';
import '../app_card.dart';
import 'home_common.dart';
class RatingBreakdownCard extends StatelessWidget {
final UserDataBean data;
const RatingBreakdownCard({super.key, required this.data});
@override
Widget build(BuildContext context) {
return HomeSectionCard(
title: AppStrings.ratingBreakdown,
rows: [
(AppStrings.musicRating, '${data.musicRating}'),
(AppStrings.newChartRating, '${data.playerNewRating}'),
(AppStrings.oldChartRating, '${data.playerOldRating}'),
(AppStrings.highestRating, '${data.highestRating}'),
],
);
}
}
class FirstPlayCard extends StatelessWidget {
final UserDataBean data;
const FirstPlayCard({super.key, required this.data});
@override
Widget build(BuildContext context) {
return HomeSectionCard(
title: AppStrings.firstPlay,
rows: [
(AppStrings.joinDate, data.firstPlayDate),
(AppStrings.firstGame, data.firstGameId),
(AppStrings.firstRomVersion, data.firstRomVersion),
(AppStrings.firstDataVersion, data.firstDataVersion),
],
);
}
}
class GameInfoFromUserDataCard extends StatelessWidget {
final UserDataBean data;
const GameInfoFromUserDataCard({super.key, required this.data});
@override
Widget build(BuildContext context) {
return HomeSectionCard(
title: AppStrings.gameInfo,
rows: [
(AppStrings.lastGame, data.lastGameId),
(AppStrings.lastPlay, data.lastPlayDate),
(AppStrings.lastLogin, data.lastLoginDate),
(AppStrings.romVersion, data.lastRomVersion),
(AppStrings.dataVersion, data.lastDataVersion),
(
AppStrings.lastRegion,
'${data.lastRegionName}${data.lastRegionId > 0 ? ' (${data.lastRegionId})' : ''}',
),
],
);
}
}
class PlayStatsCard extends StatelessWidget {
final UserDataBean data;
const PlayStatsCard({super.key, required this.data});
@override
Widget build(BuildContext context) {
return HomeSectionCard(
title: AppStrings.playStats,
rows: [
(AppStrings.playCount, '${data.playCount}'),
(AppStrings.currentPlayCount, '${data.currentPlayCount}'),
(AppStrings.totalAchievement, '${data.totalAchievement}'),
(AppStrings.totalDxScore, '${data.totalDeluxscore}'),
(AppStrings.totalSync, '${data.totalSync}'),
(AppStrings.totalAwake, '${data.totalAwake}'),
],
);
}
}
class StatusFromUserDataCard extends StatelessWidget {
/// 用连接信息(仅 JSESSIONID)恢复的会话没有走 GetUserPreviewApi,可为空。
final UserPreviewDataBean? preview;
final UserDataBean data;
const StatusFromUserDataCard({
super.key,
required this.preview,
required this.data,
});
@override
Widget build(BuildContext context) {
final p = preview;
return HomeStatusCard(
chips: [
HomeStatusChip(AppStrings.netMember, data.isNetMember),
if (p != null) HomeStatusChip(AppStrings.inherit, p.isInherit),
HomeStatusChip(
AppStrings.banState,
data.banState == 0,
trueText: AppStrings.clean,
falseText: '已封禁 (${data.banState})',
),
HomeStatusChip(
AppStrings.dailyBonus,
data.dailyBonusDate.isNotEmpty &&
!data.dailyBonusDate.startsWith('1970'),
trueText: data.dailyBonusDate,
falseText: AppStrings.notClaimed,
),
],
);
}
}
/// 可展开的原始字段卡。
class UserDataDetailCard extends StatefulWidget {
final UserDataBean data;
const UserDataDetailCard({super.key, required this.data});
@override
State<UserDataDetailCard> createState() => _UserDataDetailCardState();
}
class _UserDataDetailCardState extends State<UserDataDetailCard> {
bool _expanded = false;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final data = widget.data;
return AppCard(
onTap: () => setState(() => _expanded = !_expanded),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
AppStrings.moreDetails,
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
),
Icon(
_expanded ? Icons.expand_less : Icons.expand_more,
color: theme.colorScheme.onSurfaceVariant,
),
],
),
if (_expanded) ...[
const SizedBox(height: 14),
_detailRow(theme, AppStrings.userId, '${data.userId}'),
_detailRow(theme, AppStrings.point, '${data.point}'),
_detailRow(theme, AppStrings.totalPoint, '${data.totalPoint}'),
_detailRow(theme, AppStrings.iconId, '${data.iconId}'),
_detailRow(theme, AppStrings.nameplateId, '${data.nameplateId}'),
_detailRow(theme, AppStrings.plateId, '${data.plateId}'),
_detailRow(theme, AppStrings.frameId, '${data.frameId}'),
_detailRow(theme, AppStrings.titleId, '${data.titleId}'),
_detailRow(theme, AppStrings.trophyId, '${data.trophyId}'),
_detailRow(theme, AppStrings.partnerId, '${data.partnerId}'),
_detailRow(theme, AppStrings.gradeRank, '${data.gradeRank}'),
_detailRow(theme, AppStrings.courseRank, '${data.courseRank}'),
_detailRow(theme, AppStrings.classRank, '${data.classRank}'),
],
],
),
);
}
Widget _detailRow(ThemeData theme, String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
SizedBox(
width: 130,
child: Text(
label,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
Expanded(child: Text(value, style: theme.textTheme.bodyMedium)),
],
),
);
}
}
+232
View File
@@ -0,0 +1,232 @@
import 'package:flutter/material.dart';
import '../../config/strings.dart';
import '../app_card.dart';
import 'risk_list_widgets.dart';
import 'risk_widgets.dart';
/// 一行待提交的收藏品道具(对应 wire 上的 UserItem)。
class PendingItem {
final int kind;
final int id;
const PendingItem(this.kind, this.id);
}
/// 一行待提交的歌曲解锁,展开成 itemKind 5/6/7 的若干行。
class PendingMusic {
final int musicId;
final bool base;
final bool master;
final bool remaster;
const PendingMusic(this.musicId, this.base, this.master, this.remaster);
/// 难度由 itemKind 表达,不写在 musicDetail 上。
List<int> get itemKinds => [
if (base) 5,
if (master) 6,
if (remaster) 7,
];
String get optionLabel => [
if (base) '歌曲',
if (master) 'Master',
if (remaster) 'Re:Master',
].join('/');
/// ConstParameter.DxScoreBaseID=10000 / StrongScoreBaseID=20000
/// (`MAI2System/ConstParameter.cs:168,171`)。这段里的歌 Master 需要
/// 显式 itemKind 6,不像 id < 10000 那样在 IsUnlockMaster 里直接放过。
bool get isDeluxeRange => musicId >= 10000 && musicId < 20000;
}
/// 歌曲解锁的输入卡。输入框与三个难度勾选由本组件持有,
/// 校验通过后把待提交行交给父级;父级返回 false 表示重复、保留输入。
class UnlockMusicEditorCard extends StatefulWidget {
final bool enabled;
final bool Function(PendingMusic music) onAdd;
final ValueChanged<String> onError;
final Widget pendingList;
final bool showDxNotice;
const UnlockMusicEditorCard({
super.key,
required this.enabled,
required this.onAdd,
required this.onError,
required this.pendingList,
required this.showDxNotice,
});
@override
State<UnlockMusicEditorCard> createState() => _UnlockMusicEditorCardState();
}
class _UnlockMusicEditorCardState extends State<UnlockMusicEditorCard> {
final _controller = TextEditingController();
bool _music = false;
bool _master = false;
bool _remaster = false;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _add() {
final id = int.tryParse(_controller.text.trim()) ?? -1;
if (id <= 0) {
widget.onError(AppStrings.unlockNeedMusicId);
return;
}
if (!_music && !_master && !_remaster) {
widget.onError(AppStrings.unlockNeedOption);
return;
}
final added = widget.onAdd(PendingMusic(id, _music, _master, _remaster));
if (!added) return;
setState(() {
_controller.clear();
_music = _master = _remaster = false;
});
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final enabled = widget.enabled;
return AppCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
RiskNumberField(
controller: _controller,
label: AppStrings.unlockMusicIdLabel,
hint: AppStrings.unlockMusicIdHint,
enabled: enabled,
),
const SizedBox(height: 6),
RiskCheckRow(
value: _music,
label: AppStrings.unlockMusicOption,
enabled: enabled,
onChanged: (v) => setState(() => _music = v),
),
RiskCheckRow(
value: _master,
label: AppStrings.unlockMasterOption,
enabled: enabled,
onChanged: (v) => setState(() => _master = v),
),
RiskCheckRow(
value: _remaster,
label: AppStrings.unlockRemasterOption,
enabled: enabled,
onChanged: (v) => setState(() => _remaster = v),
),
const SizedBox(height: 8),
RiskAddButton(onPressed: enabled ? _add : null),
widget.pendingList,
// DX 段的 Master / Re:Master 不白送,勾选项里漏一个就开不出来,
// 这是这段 ID 最容易踩的坑,只在列表真含 DX 曲时才说明。
if (widget.showDxNotice) ...[
const SizedBox(height: 10),
Text(
AppStrings.unlockDxRangeNotice,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error,
height: 1.5,
),
),
],
],
),
);
}
}
/// 收藏品获取的输入卡(类型下拉 + ID)。
class CollectiblesEditorCard extends StatefulWidget {
final bool enabled;
final bool Function(PendingItem item) onAdd;
final ValueChanged<String> onError;
final Widget pendingList;
const CollectiblesEditorCard({
super.key,
required this.enabled,
required this.onAdd,
required this.onError,
required this.pendingList,
});
@override
State<CollectiblesEditorCard> createState() => _CollectiblesEditorCardState();
}
class _CollectiblesEditorCardState extends State<CollectiblesEditorCard> {
final _controller = TextEditingController();
int _kind = 3;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _add() {
final id = int.tryParse(_controller.text.trim()) ?? -1;
if (id <= 0) {
widget.onError(AppStrings.collectiblesNeedItemId);
return;
}
final added = widget.onAdd(PendingItem(_kind, id));
if (!added) return;
setState(() => _controller.clear());
}
@override
Widget build(BuildContext context) {
final enabled = widget.enabled;
return AppCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
RiskDropdownField<int>(
value: _kind,
label: AppStrings.collectiblesItemKindLabel,
enabled: enabled,
items: [
for (final kind in AppStrings.collectiblesItemKinds)
DropdownMenuItem(
value: kind,
child: Text(
'${AppStrings.collectiblesItemKindName(kind)} ($kind)',
),
),
],
onChanged: (v) => setState(() => _kind = v),
),
const SizedBox(height: 12),
RiskNumberField(
controller: _controller,
label: AppStrings.collectiblesItemIdLabel,
hint: AppStrings.collectiblesItemIdHint,
enabled: enabled,
),
const SizedBox(height: 8),
RiskAddButton(onPressed: enabled ? _add : null),
widget.pendingList,
],
),
);
}
}
+186
View File
@@ -0,0 +1,186 @@
import 'package:flutter/material.dart';
import '../../config/strings.dart';
/// 全宽的「添加到列表」按钮,样式统一。
class RiskAddButton extends StatelessWidget {
final VoidCallback? onPressed;
const RiskAddButton({super.key, this.onPressed});
@override
Widget build(BuildContext context) {
return SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: onPressed,
icon: const Icon(Icons.add, size: 18),
label: const Text(AppStrings.listAddButton),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
),
);
}
}
/// 待提交列表:标题带数量,空列表时显示提示。
class RiskPendingList extends StatelessWidget {
final String title;
final String emptyHint;
final int count;
final List<Widget> rows;
const RiskPendingList({
super.key,
required this.title,
required this.emptyHint,
required this.count,
required this.rows,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 14),
Text(
'$title ($count)',
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 6),
if (count == 0)
Text(
emptyHint,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
)
else
...rows,
],
);
}
}
/// 待提交列表的一行:等宽文本 + 删除按钮。
class RiskPendingRow extends StatelessWidget {
final String leading;
final String trailing;
final VoidCallback onRemove;
final bool enabled;
const RiskPendingRow({
super.key,
required this.leading,
required this.trailing,
required this.onRemove,
this.enabled = true,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
children: [
Expanded(
child: Text(
'$leading $trailing',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
),
),
),
IconButton(
icon: const Icon(Icons.close, size: 18),
tooltip: AppStrings.listRemoveTooltip,
onPressed: enabled ? onRemove : null,
color: theme.colorScheme.onSurfaceVariant,
visualDensity: VisualDensity.compact,
),
],
);
}
}
/// 风险页通用下拉框(圆角 10、内容内边距 14)。
class RiskDropdownField<T> extends StatelessWidget {
final T value;
final String label;
final List<DropdownMenuItem<T>> items;
final ValueChanged<T>? onChanged;
final bool enabled;
const RiskDropdownField({
super.key,
required this.value,
required this.label,
required this.items,
required this.onChanged,
this.enabled = true,
});
@override
Widget build(BuildContext context) {
return DropdownButtonFormField<T>(
initialValue: value,
decoration: InputDecoration(
labelText: label,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
),
contentPadding: const EdgeInsets.all(14),
),
items: items,
onChanged: !enabled || onChanged == null
? null
: (v) {
if (v == null) return;
onChanged!(v);
},
);
}
}
/// 与 [RiskDropdownField] 配对的普通文本输入框,保持同样的圆角与内边距。
class RiskNumberField extends StatelessWidget {
final TextEditingController controller;
final String label;
final String hint;
final bool enabled;
const RiskNumberField({
super.key,
required this.controller,
required this.label,
required this.hint,
this.enabled = true,
});
@override
Widget build(BuildContext context) {
return TextField(
controller: controller,
enabled: enabled,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: label,
hintText: hint,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
),
contentPadding: const EdgeInsets.all(14),
),
);
}
}
+96
View File
@@ -0,0 +1,96 @@
import 'package:flutter/widgets.dart';
import '../../config/strings.dart';
import '../../config/title_server_config.dart';
import '../../services/title_api_service.dart';
import '../step_progress.dart';
/// 高危功能共用的执行状态:发包中、自动退登、阶段与错误。
///
/// 六个页面原来各自维护一份完全相同的字段和 `_updateStep`,导致同样的
/// 「失败落到哪一步、退登时序怎么走」要在每个文件里各看一遍。
mixin RiskRunMixin<T extends StatefulWidget> on State<T> {
bool riskFetching = false;
bool riskRunning = false;
bool riskAutoLogout = true;
RiskStep riskStep = RiskStep.idle;
String riskStepMessage = '';
String? riskError;
void updateRiskStep(RiskStep step, [String? message]) {
if (!mounted) return;
setState(() {
riskStep = step;
riskStepMessage = message ?? '';
});
}
void setRiskFetching(bool value) {
if (!mounted) return;
setState(() => riskFetching = value);
}
void setRiskRunning(bool value) {
if (!mounted) return;
setState(() => riskRunning = value);
}
void setRiskAutoLogout(bool value) {
if (!mounted) return;
setState(() => riskAutoLogout = value);
}
void clearRiskError() {
if (!mounted || riskError == null) return;
setState(() => riskError = null);
}
/// 把异常写进阶段卡。`TitleApiException` 的 message 已经是给用户看的文案,
/// 其他异常保留 `toString()` 以便排查。
void reportRiskError(Object error) {
final message = describeApiError(error);
if (!mounted) return;
setState(() {
riskError = message;
riskStep = RiskStep.failed;
riskStepMessage = message;
});
}
/// 成功后的自动退登:留一点时间让阶段卡显示完成态,再由 HomePage 结算并
/// pop 回标题页。调用方负责先判断 `onExitToTitle` 是否存在。
Future<void> autoLogoutAndExit(
Future<void> Function()? onExitToTitle, {
Duration delay = const Duration(seconds: 2),
}) async {
if (!riskAutoLogout || onExitToTitle == null) return;
await Future<void>.delayed(delay);
if (!mounted) return;
updateRiskStep(RiskStep.logout, AppStrings.exitingToTitle);
await onExitToTitle();
}
}
/// 执行前的统一门禁,顺序固定为「配置 → 登录态 → 冷却」。
///
/// 返回要提示给用户的文案,`null` 表示可以继续。功能票只依赖
/// `loginDateTime`,其余 `UpsertUserAll` 页面还需要 `loginId`。
String? riskPreconditionError({
required int? loginDateTime,
required int? loginId,
required int cooldownRemaining,
required String notLoggedInMessage,
required String Function(int remaining) cooldownNotice,
bool requireLoginId = true,
}) {
if (!TitleServerConfigHolder().isConfigured) {
return AppStrings.ticketNotConfigured;
}
if (loginDateTime == null || (requireLoginId && loginId == null)) {
return notLoggedInMessage;
}
if (cooldownRemaining > 0) {
return cooldownNotice(cooldownRemaining);
}
return null;
}
+332
View File
@@ -0,0 +1,332 @@
import 'package:flutter/material.dart';
import '../../config/responsive.dart';
import '../app_card.dart';
import '../app_notice.dart';
/// 尚未配置 Title Server 时的占位视图。
class RiskConfigMissingView extends StatelessWidget {
final String message;
const RiskConfigMissingView({
super.key,
required this.message,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.settings_ethernet,
size: 48,
color: theme.colorScheme.primary,
),
const SizedBox(height: 16),
Text(message, style: theme.textTheme.titleMedium),
],
),
),
);
}
}
/// 高危页统一的滚动容器与内边距。
class RiskPageBody extends StatelessWidget {
final List<Widget> children;
const RiskPageBody({super.key, required this.children});
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: responsiveBody(
context,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: children,
),
),
);
}
}
/// 未登录 / 冷却横幅。
class RiskStatusNotices extends StatelessWidget {
final bool isLoggedIn;
final int cooldownRemaining;
final String notLoggedInText;
final String Function(int remaining) cooldownText;
const RiskStatusNotices({
super.key,
required this.isLoggedIn,
required this.cooldownRemaining,
required this.notLoggedInText,
required this.cooldownText,
});
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
if (!isLoggedIn)
AppNotice(
notLoggedInText,
error: true,
icon: Icons.warning_amber_rounded,
),
if (isLoggedIn && cooldownRemaining > 0)
AppNotice(
cooldownText(cooldownRemaining),
icon: Icons.timer_outlined,
),
],
);
}
}
/// 功能说明卡。[danger] 用于高危中转页的红色警示卡。
class RiskFeatureCard extends StatelessWidget {
final IconData icon;
final String title;
final String? description;
final Widget? child;
final bool danger;
const RiskFeatureCard({
super.key,
required this.icon,
required this.title,
this.description,
this.child,
this.danger = false,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final tint = danger ? theme.colorScheme.error : null;
return AppCard(
borderColor:
danger ? theme.colorScheme.error.withValues(alpha: 0.4) : null,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SectionTitle(icon: icon, title: title, color: tint),
if (description != null) ...[
const SizedBox(height: 12),
Text(
description!,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
height: 1.5,
),
),
],
?child,
],
),
);
}
}
/// 「已拉取服务器数据」状态 + 刷新按钮。各页原来各抄一份 Row。
class RiskFetchCard extends StatelessWidget {
final bool hasData;
final String statusText;
final String loadingLabel;
final String actionLabel;
final String? error;
final bool loading;
final bool enabled;
final VoidCallback onFetch;
const RiskFetchCard({
super.key,
required this.hasData,
required this.statusText,
required this.loadingLabel,
required this.actionLabel,
required this.loading,
required this.onFetch,
this.error,
this.enabled = true,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return AppCard(
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(
statusText,
style: theme.textTheme.bodySmall?.copyWith(
color: hasData
? Colors.green
: theme.colorScheme.onSurfaceVariant,
),
),
),
SizedBox(
height: 32,
child: OutlinedButton.icon(
onPressed: loading || !enabled ? null : onFetch,
icon: loading
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.refresh, size: 16),
label: Text(
loading ? loadingLabel : actionLabel,
style: theme.textTheme.labelSmall,
),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
),
],
),
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',
),
),
),
],
],
),
);
}
}
/// 高危页底部的执行按钮。
class RiskRunButton extends StatelessWidget {
final bool running;
final bool enabled;
final IconData icon;
final String label;
final String runningLabel;
final String? disabledLabel;
final VoidCallback onPressed;
const RiskRunButton({
super.key,
required this.running,
required this.enabled,
required this.icon,
required this.label,
required this.runningLabel,
required this.onPressed,
this.disabledLabel,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: enabled && !running ? onPressed : null,
icon: running
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: theme.colorScheme.onPrimary,
),
)
: Icon(icon, size: 20),
label: Text(
running ? runningLabel : (disabledLabel ?? label),
),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
);
}
}
/// 勾选行(解锁难度 / 万花筒动作共用)。
class RiskCheckRow extends StatelessWidget {
final bool value;
final String label;
final bool enabled;
final ValueChanged<bool> onChanged;
const RiskCheckRow({
super.key,
required this.value,
required this.label,
required this.enabled,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return InkWell(
borderRadius: BorderRadius.circular(10),
onTap: enabled ? () => onChanged(!value) : null,
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),
),
),
),
],
),
);
}
}