Merge pull request #2 from AirFoundation/refactor/core-login

This commit is contained in:
Bad0RANG3 committed 2026-10-25 15:52:38 +08:00
commit 40f358b228
55 files changed
+4079 -3084

No files matched your search

+42 -5
View File
@@ -41,16 +41,31 @@ login client for the maimai DX arcade game network.
``` ```
lib/ lib/
├── main.dart # 入口:main() + MyApp(MaterialApp) ├── main.dart # 入口:main() + MyApp(MaterialApp)
├── config/ # strings / responsive / TitleServerConfig
├── core/
│ ├── json_value.dart # 宽松 JSON 数值转换(jsonInt / jsonBool / jsonMapList …)
│ └── date_time_format.dart# UTC+8 时间戳与 yyyy-MM-dd HH:mm:ss.f 格式化
├── pages/ ├── pages/
│ ├── login_page.dart # 登录页(QR 粘贴 / 图片识别) │ ├── login_page.dart # 登录页(QR 粘贴 / 图片识别)
│ ├── home_page.dart # 主页骨架(底部导航 + 各 tab) │ ├── home_page.dart # 主页骨架(HomeTab 枚举 + 懒构建 IndexedStack)
│ ├── home/
│ │ ├── home_tab.dart # 六个页签的标题 / 图标 / 顺序
│ │ └── home_tab_view.dart # 首页内容(加载 / 错误 / preview / userData 分支)
│ ├── settings_page.dart # Title Server / 机器参数配置 │ ├── settings_page.dart # Title Server / 机器参数配置
│ └── ... # 各功能页(ticket / best50 / 风险操作等) │ └── ... # 各功能页(ticket / best50 / 风险操作等)
├── models/
│ ├── session_model.dart # 会话与 Cookie 的唯一持有者(ChangeNotifier 单例)
│ └── ...
├── widgets/ ├── widgets/
│ ├── cooldown_mixin.dart # CooldownMixin:登录后冷却倒计时 │ ├── cooldown_mixin.dart # CooldownMixin:登录后冷却倒计时
│ ├── step_progress.dart # RiskStep + StepProgressCard │ ├── step_progress.dart # RiskStep + StepProgressCard
│ ├── app_card.dart # AppCard / SectionTitle / AutoLogoutToggle │ ├── app_card.dart # AppCard / SectionTitle / AutoLogoutToggle
│ ├── app_notice.dart # AppNotice + context.showSnack │ ├── app_notice.dart # AppNotice + context.showSnack
│ ├── home/ # 主页资料 / 统计 / 调试卡片
│ ├── risk/
│ │ ├── risk_list_widgets.dart # 待提交列表与表单控件
│ │ ├── music_risk_editors.dart # 解锁歌曲 / 收藏品编辑器
│ │ └── risk_run_mixin.dart # 风险执行阶段与错误上报
│ └── best50_poster.dart # B50 成绩图 │ └── best50_poster.dart # B50 成绩图
└── services/ └── services/
├── api_service.dart # Aime 登录 API 客户端 ├── api_service.dart # Aime 登录 API 客户端
@@ -69,13 +84,35 @@ lib/
- **QR decoding is native-only**: `services/qr_service.dart` calls the - **QR decoding is native-only**: `services/qr_service.dart` calls the
`dev.naominet.ichino/qr_scanner` MethodChannel, implemented by Android's `MainActivity` and `dev.naominet.ichino/qr_scanner` MethodChannel, implemented by Android's `MainActivity` and
iOS's `SceneDelegate`. Desktop has no handler, so image-based QR scanning is mobile-only. iOS's `SceneDelegate`. Desktop has no handler, so image-based QR scanning is mobile-only.
- **Auth flow**: `ApiService.login()` takes a QR code token (last 64 chars), builds a SHA-256 key - **Auth flow**: `ApiService.login()` is a port of Astral's `api/Chime.py`. It normalizes the pasted
from `chipID + JST-timestamp + chimeSalt`, and POSTs JSON to the configured Aime URL. A successful input (84-char `SGWCMAID…` string, or a `wq.wahlap.net/qrcode/req/….html` link) and POSTs
response has `errorID: 0` and returns `userID` + `token`. `{chipID, openGameID, key, qrCode, timestamp, titlekey}` to the hardcoded aimedb endpoint
`https://ai.sys-allnet.cn/wc_aime/api/get_data`. The `key` is
`SHA-256(完整 Keychip + timestamp + 内置签名盐)` upper-hex; `qrCode` is the 64-char payload
(`input[20:]`), not the whole string. The request **must not carry a Content-Type header** —
aimedb answers `errorID: 57` when it does — so it is sent via `http.Request` + `bodyBytes`.
`chipID` uses the full hyphenated Keychip (`TitleServerConfig.fullKeychipId`), while the title
server keeps using the 11-char short `clientId`. A successful response has `errorID: 0` and
returns `userID` + `token`.
- **Title server transport**: `TitleApiService` derives the URL path from - **Title server transport**: `TitleApiService` derives the URL path from
`MD5(apiName + salt + obfuscateParam)`, JSON → ZLib → AES-CBC encodes/decodes, and keeps the `MD5(apiName + salt + obfuscateParam)`, JSON → ZLib → AES-CBC encodes/decodes, and keeps the
`JSESSIONID` cookie. `getUserPreview` and every other API go through the same `_callApi` helper. `JSESSIONID` cookie. `getUserPreview` and every other API go through the same `_callApi` helper.
- **Timestamp is JST (UTC+9)** for Aime key derivation, formatted `yyMMddHHmmss`. - **Session and cookies have one owner**: `SessionModel` holds the latest `JSESSIONID`; pages build
services with `SessionModel.createTitleService(cookies: …)` instead of snapshotting the cookie
themselves. Any response can rotate the cookie, so `TitleApiService` writes it back through
`onCookiesChanged` and `SessionModel.updateCookies` notifies listeners — visited tabs then rebuild
with the fresh value. Directly-constructed page tests fall back to the cookie passed to the page.
`lastRawResponse` (the homepage debug card) is per service instance, not static; `SessionModel`
forwards the restored preview body through `SessionRestoreResult`.
- **Home tabs are lazy**: the `HomeTab` enum owns order/title/icons; `HomePage` keeps a
`_visitedTabs` set and fills unvisited `IndexedStack` slots with `SizedBox.shrink()`, so ticket /
B50 / risk pages do not fire their startup requests until first opened.
- **User-facing API errors**: call `describeApiError(e)` — `TitleApiException` contributes only its
message (protocol failures stay readable), anything else keeps `toString()` for debugging.
Use the `core/json_value.dart` helpers for loose server JSON instead of ad-hoc casts, and
`core/date_time_format.dart` for UTC+8 timestamps.
- **The login timestamp is the device's local wall-clock time** (`yymmddHHmmss`), matching the
arcade cabinet; it is not pinned to any fixed UTC offset.
### Notable Dependencies ### Notable Dependencies
+35 -6
View File
@@ -16,7 +16,7 @@ Android / iOS / Windows / macOS / Linux。
| 票据 | 功能票查询与使用 | | 票据 | 功能票查询与使用 |
| B50 | Best 50 成绩图,可导出 PNG(移植自 Empurple 的 `Best50ImageRenderer`) | | B50 | Best 50 成绩图,可导出 PNG(移植自 Empurple 的 `Best50ImageRenderer`) |
| 风险 | 歌曲解锁、收藏品获取、旅行伙伴发放与编组(含等级)、修改总 Rating、添加舞里程、一键跑图、万花筒专区 | | 风险 | 歌曲解锁、收藏品获取、旅行伙伴发放与编组(含等级)、修改总 Rating、添加舞里程、一键跑图、万花筒专区 |
| 设置 | Title Server / Auth Server / 机器参数,支持配置导入导出 | | 设置 | Title Server / 机器参数 / Keychip,支持配置导入导出 |
| 连接信息(实验性) | 主页导出 uid + JSESSIONID + token 的 Base64 快照,登录页粘贴即可恢复会话;内容未加密,请勿外传 | | 连接信息(实验性) | 主页导出 uid + JSESSIONID + token 的 Base64 快照,登录页粘贴即可恢复会话;内容未加密,请勿外传 |
| 账号备份 | 主页只读地把服务器上的用户数据汇成一串 JSON(道具、舞里程、游玩统计、旅行伙伴、区域与万花筒进度、Rating/成绩)。全程只发 `GetUser*Api`,不写任何数据;单段读失败不整体作废,失败段列在结果里 | | 账号备份 | 主页只读地把服务器上的用户数据汇成一串 JSON(道具、舞里程、游玩统计、旅行伙伴、区域与万花筒进度、Rating/成绩)。全程只发 `GetUser*Api`,不写任何数据;单段读失败不整体作废,失败段列在结果里 |
@@ -45,10 +45,16 @@ flutter build ios --release --no-codesign # iOS(无证书时只验证可编
lib/ lib/
├── main.dart # 入口(MyApp + MaterialApp) ├── main.dart # 入口(MyApp + MaterialApp)
├── config/ # 字符串、响应式布局、Title Server 配置 ├── config/ # 字符串、响应式布局、Title Server 配置
├── models/ # API 数据模型(UserData / UserPreview / UserRating / UserCharacter / UserKaleidxScope / Session) ├── core/ # 宽松 JSON 解析、UTC+8 时间格式化
├── pages/ # 登录页与各功能页面 ├── models/ # API 数据模型与 SessionModel(会话 / Cookie 的唯一持有者)
├── pages/
│ ├── home/ # 主页页签枚举与首页视图(HomeTab / HomeTabView)
│ └── ... # 登录页与各功能页面
├── services/ # 传输层、payload 组装、乐曲元数据、导出、RA 计算、调试日志 ├── services/ # 传输层、payload 组装、乐曲元数据、导出、RA 计算、调试日志
└── widgets/ # 可复用视觉组件(B50 海报、卡片/提示、冷却 mixin、阶段进度卡) └── widgets/
├── home/ # 主页头像、资料、统计、调试卡片
├── risk/ # 风险页共享脚手架、列表控件与编辑器
└── ... # 可复用视觉组件(B50 海报、卡片/提示、冷却 mixin、阶段进度卡)
tools/ # 离线开发工具(不参与构建产物) tools/ # 离线开发工具(不参与构建产物)
data/id_mapping/ # 由 tools/gen_id_mapping.dart 从机台数据包抽出的 ID 映射表 data/id_mapping/ # 由 tools/gen_id_mapping.dart 从机台数据包抽出的 ID 映射表
``` ```
@@ -60,21 +66,41 @@ data/id_mapping/ # 由 tools/gen_id_mapping.dart 从机台数据包抽
- `widgets/cooldown_mixin.dart` —`CooldownMixin`:登录后冷却倒计时(`Timer.periodic`)。 - `widgets/cooldown_mixin.dart` —`CooldownMixin`:登录后冷却倒计时(`Timer.periodic`)。
- `widgets/step_progress.dart` —`RiskStep` + `StepProgressCard`:执行阶段枚举与进度卡。 - `widgets/step_progress.dart` —`RiskStep` + `StepProgressCard`:执行阶段枚举与进度卡。
- `widgets/risk/risk_list_widgets.dart` —`RiskPendingList` / `RiskPendingRow` /
`RiskDropdownField` / `RiskNumberField` / `RiskAddButton`:待提交列表与表单控件。
- `widgets/risk/music_risk_editors.dart` — 解锁歌曲与收藏品的输入编辑器
(`PendingItem` / `PendingMusic`)。
- `widgets/app_card.dart` —`AppCard` / `SectionTitle` / `AutoLogoutToggle`。 - `widgets/app_card.dart` —`AppCard` / `SectionTitle` / `AutoLogoutToggle`。
- `widgets/app_notice.dart` —`AppNotice`(未登录/冷却/错误横幅)与 `context.showSnack`。 - `widgets/app_notice.dart` —`AppNotice`(未登录/冷却/错误横幅)与 `context.showSnack`。
- `core/json_value.dart` —`jsonInt` / `jsonDouble` / `jsonBool` / `jsonString` /
`jsonMap` / `jsonMapList`:服务器字段类型不稳定,统一宽松转换。
- `core/date_time_format.dart` — UTC+8 时间戳与 `yyyy-MM-dd HH:mm:ss.f` 格式化。
- `models/session_model.dart` —`SessionModel.createTitleService()` 统一构建会话服务;
Cookie 在任意响应里轮换时经 `onCookiesChanged` 回写,页面不再拿旧快照。
- `services/title_api_service.dart` —`TitleApiService.fromHolder()` 统一构建会话,`config` getter - `services/title_api_service.dart` —`TitleApiService.fromHolder()` 统一构建会话,`config` getter
供 payload builder 复用。 供 payload builder 复用;`describeApiError()` 统一面向用户的错误文本。
- `services/user_all_payload_builder.dart` — 占位 `musicData`(`placeholderMusicData`)。 - `services/user_all_payload_builder.dart` — 占位 `musicData`(`placeholderMusicData`)。
- `services/api_log.dart` —`ApiLog`:传输层日志,仅 debug 输出,release 下不落 token/cookie。 - `services/api_log.dart` —`ApiLog`:传输层日志,仅 debug 输出,release 下不落 token/cookie。
主页的六个页签由 `HomeTab` 枚举驱动,`IndexedStack` 只构建访问过的页签,
票据 / B50 / 风险 hub 不会再在启动时各自发一轮请求。 `TitleApiService.lastRawResponse`
是实例状态,`SessionModel` 把预览原文透传给主页调试卡,不会跨请求串数据。
平台差异只体现在原生侧:QR 解码由 Android 的 `MainActivity` 与 iOS 的 平台差异只体现在原生侧:QR 解码由 Android 的 `MainActivity` 与 iOS 的
`SceneDelegate` 通过 `dev.naominet.ichino/qr_scanner` 这个 MethodChannel 提供, `SceneDelegate` 通过 `dev.naominet.ichino/qr_scanner` 这个 MethodChannel 提供,
桌面平台没有该 handler,因此从图片识别二维码仅在移动端可用。 桌面平台没有该 handler,因此从图片识别二维码仅在移动端可用。
## 关键实现说明 ## 关键实现说明
- **扫码登录**:`ApiService` 对齐 Astral 的 `api/Chime.py` —— 端点与签名盐写死在代码里
(`https://ai.sys-allnet.cn/wc_aime/api/get_data`),签名是
`SHA-256(完整 Keychip + 本机本地时间 + 签名盐)`,请求体带 `titlekey: SDGB`,
`qrCode` 只发 84 串的第 21 位起的 64 位载荷;请求头**不能带 Content-Type**,
带了 aimedb 直接回 `errorID=57`。输入既可以是 84 位二维码串,也可以是
`wq.wahlap.net/qrcode/req/….html` 二维码链接;Keychip 设置里填 11 位短值也能用,
会按 placeId 补成完整形式(扫码登录用完整形式,游戏服务器才用短值)。
- **传输层**:`TitleApiService` 负责 `MD5(apiName + salt + obfuscateParam)` 生成 URL 路径、 - **传输层**:`TitleApiService` 负责 `MD5(apiName + salt + obfuscateParam)` 生成 URL 路径、
JSON → ZLib → AES-CBC 编解码、`Set-Cookie` 会话维持。时间戳用 JST(UTC+9)。 JSON → ZLib → AES-CBC 编解码、`Set-Cookie` 会话维持。
- **B50 成绩图**:底图 `assets/b50/base.png`(1762×1850)之上按固定像素坐标叠加头像、 - **B50 成绩图**:底图 `assets/b50/base.png`(1762×1850)之上按固定像素坐标叠加头像、
昵称、50 张卡片与页脚;卡片 300×100、每行 5 张,BEST35 从 y=350 起、BEST15 固定从 昵称、50 张卡片与页脚;卡片 300×100、每行 5 张,BEST35 从 y=350 起、BEST15 固定从
y=1250 起。RA 与评级由 `RatingCalculator` 计算,曲名与定数取自 diving-fish 的 y=1250 起。RA 与评级由 `RatingCalculator` 计算,曲名与定数取自 diving-fish 的
@@ -170,6 +196,9 @@ data/id_mapping/ # 由 tools/gen_id_mapping.dart 从机台数据包抽
`dependency_overrides`)。2.3.x 起它依赖 `jni`,而 `jni` 把 `windows`/`linux` `dependency_overrides`)。2.3.x 起它依赖 `jni`,而 `jni` 把 `windows`/`linux`
也声明成 `ffiPlugin`,只要它出现在依赖树里,桌面构建就会去链接 `jvm.lib` 并失败。 也声明成 `ffiPlugin`,只要它出现在依赖树里,桌面构建就会去链接 `jvm.lib` 并失败。
`share_plus` 间接依赖 `path_provider`,所以不能靠去掉直接依赖解决。 `share_plus` 间接依赖 `path_provider`,所以不能靠去掉直接依赖解决。
同理,`shared_preferences_android` 2.5.x 也会引入 `jni`;`pubspec.lock` 目前锁在
2.4.23。跑完 `flutter pub get` 后如果 lock 被悄悄升到 2.5.x,请恢复该 lock 项,
否则 `jni` 会重新出现在 `generated_plugins.cmake` 的 FFI 列表里。
2. **命令行构建请显式指定 JDK**。Android Studio 常驻的 Gradle daemon 用它自带的 JBR, 2. **命令行构建请显式指定 JDK**。Android Studio 常驻的 Gradle daemon 用它自带的 JBR,
而 JBR 不含 `jlink.exe`,会导致 而 JBR 不含 `jlink.exe`,会导致
`:flutter_plugin_android_lifecycle:compileReleaseJavaWithJavac` 失败。 `:flutter_plugin_android_lifecycle:compileReleaseJavaWithJavac` 失败。
+5 -7
View File
@@ -12,7 +12,7 @@ class AppStrings {
// Login // Login
static const appTitle = 'Ichino'; static const appTitle = 'Ichino';
static const qrCodeToken = 'QR Code 令牌'; static const qrCodeToken = 'QR Code 令牌';
static const qrHint = '粘贴二维码解析内容... / 粘贴连接信息'; static const qrHint = '粘贴二维码解析内容或二维码链接... / 粘贴连接信息';
static const uploadQR = '上传二维码'; static const uploadQR = '上传二维码';
static const login = '登录'; static const login = '登录';
static const loggingIn = '登录中...'; static const loggingIn = '登录中...';
@@ -21,7 +21,7 @@ class AppStrings {
static const qrScanError = '解析失败'; static const qrScanError = '解析失败';
static const forcePreviewApi = '强制使用 Preview API'; static const forcePreviewApi = '强制使用 Preview API';
static const loginFailed = '登录失败'; static const loginFailed = '登录失败';
static const qrTokenUnrecognized = '没认出 QR 令牌(内容里应当含 SGWCMAID),也可以粘贴「连接信息」。'; static const qrTokenUnrecognized = '没认出 QR 令牌';
static const requestFailed = '请求失败'; static const requestFailed = '请求失败';
static const settingsTooltip = '设置'; static const settingsTooltip = '设置';
@@ -164,6 +164,7 @@ class AppStrings {
static const authServerSettings = 'Auth Server'; static const authServerSettings = 'Auth Server';
static const machineSettings = '机器信息'; static const machineSettings = '机器信息';
static const save = '保存'; static const save = '保存';
static const configSaved = '配置已保存';
static const labelTitleServerUrl = 'Title Server URL'; static const labelTitleServerUrl = 'Title Server URL';
static const hintTitleServerUrl = ''; static const hintTitleServerUrl = '';
static const labelAesKey = 'AES Key'; static const labelAesKey = 'AES Key';
@@ -185,11 +186,8 @@ class AppStrings {
static const labelPlaceName = 'Place 名称'; static const labelPlaceName = 'Place 名称';
static const hintPlaceName = 'Place name'; static const hintPlaceName = 'Place name';
static const labelKeychipId = 'Keychip ID'; static const labelKeychipId = 'Keychip ID';
static const hintKeychipId = 'ID'; static const helpKeychipId =
static const labelAimeUrl = 'Aime URL'; '机台完整 Keychip(如 A63E-01C30471007);只填 11 位短值时按 Place ID 补全';
static const hintAimeUrl = '';
static const labelAimeSalt = 'Aime Salt';
static const hintAimeSalt = 'API Key 派生用 Salt';
static String fieldRequired(String label) => '$label 不能为空'; static String fieldRequired(String label) => '$label 不能为空';
// Config Export/Import // Config Export/Import
+11 -13
View File
@@ -14,9 +14,6 @@ class TitleServerConfig {
final String regionName; final String regionName;
final String placeName; final String placeName;
final String keychipId; final String keychipId;
final String aimeUrl;
final String aimeSalt;
final String openGameID;
const TitleServerConfig({ const TitleServerConfig({
required this.titleServerUrl, required this.titleServerUrl,
@@ -30,14 +27,21 @@ class TitleServerConfig {
this.regionName = '', this.regionName = '',
this.placeName = '', this.placeName = '',
this.keychipId = '', this.keychipId = '',
this.aimeUrl = '', });
this.aimeSalt = '',
String? openGameID,
}) : openGameID = openGameID ?? 'MAID';
List<int> get aesKeyBytes => utf8.encode(aesKey); List<int> get aesKeyBytes => utf8.encode(aesKey);
List<int> get aesIvBytes => utf8.encode(aesIv); 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() => { Map<String, dynamic> toJson() => {
'titleServerUrl': titleServerUrl, 'titleServerUrl': titleServerUrl,
'aesKey': aesKey, 'aesKey': aesKey,
@@ -50,9 +54,6 @@ class TitleServerConfig {
'regionName': regionName, 'regionName': regionName,
'placeName': placeName, 'placeName': placeName,
'keychipId': keychipId, 'keychipId': keychipId,
'aimeUrl': aimeUrl,
'aimeSalt': aimeSalt,
'openGameID': openGameID,
}; };
factory TitleServerConfig.fromJson(Map<String, dynamic> json) { factory TitleServerConfig.fromJson(Map<String, dynamic> json) {
@@ -68,9 +69,6 @@ class TitleServerConfig {
regionName: json['regionName'] as String? ?? '', regionName: json['regionName'] as String? ?? '',
placeName: json['placeName'] as String? ?? '', placeName: json['placeName'] as String? ?? '',
keychipId: json['keychipId'] 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 { class MyApp extends StatelessWidget {
const MyApp({super.key}); /// 默认从登录页启动;开发预览等入口可传入别的根页面。
final Widget? home;
const MyApp({super.key, this.home});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -31,7 +34,7 @@ class MyApp extends StatelessWidget {
brightness: Brightness.dark, brightness: Brightness.dark,
), ),
), ),
home: const LoginPage(), home: home ?? const LoginPage(),
); );
} }
} }
+32 -8
View File
@@ -17,6 +17,9 @@ class SessionRestoreResult {
final UserDataBean? userData; final UserDataBean? userData;
final UserPreviewDataBean? preview; final UserPreviewDataBean? preview;
/// 恢复过程中 GetUserPreviewApi 的响应原文,供主页调试卡展示。
final String? rawPreviewResponse;
/// 服务器认为这次会话仍在登录中(`preview.isLogin`),或刚用令牌登录成功。 /// 服务器认为这次会话仍在登录中(`preview.isLogin`),或刚用令牌登录成功。
/// 只有为 `true` 时票据与风险页才开放写入,并且照常吃 60 秒登录后冷却。 /// 只有为 `true` 时票据与风险页才开放写入,并且照常吃 60 秒登录后冷却。
final bool loggedIn; final bool loggedIn;
@@ -25,6 +28,7 @@ class SessionRestoreResult {
required this.reusedCookie, required this.reusedCookie,
this.userData, this.userData,
this.preview, this.preview,
this.rawPreviewResponse,
this.loggedIn = false, this.loggedIn = false,
}); });
} }
@@ -44,11 +48,22 @@ class SessionModel extends ChangeNotifier {
UserLoginResult? get gameLogin => _gameLogin; UserLoginResult? get gameLogin => _gameLogin;
String? get cookies => _cookies; String? get cookies => _cookies;
static String extractQRCode(String qrCodeToken) => /// 构造会回写 Cookie 的服务。
ApiService.extractQRCode(qrCodeToken); ///
/// 生产环境优先用会话里最新的 JSESSIONID:Title Server 可能在任意响应里
/// 轮换 Cookie,页面若一直拿着进入时的快照,后续请求就会带着旧值。
/// 直接构造页面的测试没有会话,才退回调用方传入的 [cookies]。
TitleApiService? createTitleService({String? cookies}) {
return TitleApiService.fromHolder(
cookies: _cookies ?? cookies,
onCookiesChanged: updateCookies,
);
}
Future<LoginResult> loginWithQr(String qrCodeToken) async { /// [qrInput] 可以是 84 位二维码串,也可以是主页导出的二维码链接;
final result = await _apiService.login(qrCodeToken); /// 格式不合预期时 [ApiService.login] 抛 [FormatException]。
Future<LoginResult> loginWithQr(String qrInput) async {
final result = await _apiService.login(qrInput);
if (result.success) { if (result.success) {
_loginResult = result; _loginResult = result;
notifyListeners(); notifyListeners();
@@ -79,7 +94,11 @@ class SessionModel extends ChangeNotifier {
final config = TitleServerConfigHolder().config; final config = TitleServerConfigHolder().config;
if (config == null) return; if (config == null) return;
final service = TitleApiService(config, cookies: cookies ?? _cookies); final service = TitleApiService(
config,
cookies: cookies ?? _cookies,
onCookiesChanged: updateCookies,
);
try { try {
await service.userLogout( await service.userLogout(
userId: userId, userId: userId,
@@ -136,7 +155,10 @@ class SessionModel extends ChangeNotifier {
SessionShare share, SessionShare share,
String cookie, String cookie,
) async { ) async {
final service = TitleApiService.fromHolder(cookies: cookie); final service = TitleApiService.fromHolder(
cookies: cookie,
onCookiesChanged: updateCookies,
);
if (service == null) return null; if (service == null) return null;
try { try {
@@ -159,6 +181,7 @@ class SessionModel extends ChangeNotifier {
} }
} }
updateCookies(service.cookies); updateCookies(service.cookies);
final rawPreviewResponse = service.lastRawResponse;
// 服务器说这次会话还挂在机上,就按已登录处理:loginDateTime 取当下, // 服务器说这次会话还挂在机上,就按已登录处理:loginDateTime 取当下,
// 风险页与票据照常吃「登录后 60 秒冷却」,不用重新 UserLoginApi。 // 风险页与票据照常吃「登录后 60 秒冷却」,不用重新 UserLoginApi。
@@ -177,6 +200,7 @@ class SessionModel extends ChangeNotifier {
reusedCookie: true, reusedCookie: true,
userData: userData, userData: userData,
preview: preview, preview: preview,
rawPreviewResponse: rawPreviewResponse,
loggedIn: _gameLogin != null, loggedIn: _gameLogin != null,
); );
} on TitleApiException { } on TitleApiException {
@@ -185,9 +209,9 @@ class SessionModel extends ChangeNotifier {
} }
void updateCookies(String? cookies) { void updateCookies(String? cookies) {
if (cookies != null && cookies.isNotEmpty) { if (cookies == null || cookies.isEmpty || cookies == _cookies) return;
_cookies = cookies; _cookies = cookies;
} notifyListeners();
} }
void reset() { void reset() {
+8 -18
View File
@@ -1,3 +1,5 @@
import '../core/json_value.dart';
/// GetUserRatingApi 之外的角色(旅行伙伴)数据。 /// GetUserRatingApi 之外的角色(旅行伙伴)数据。
/// ///
/// 对应客户端 `Net.VO.Mai2.UserCharacter`:只有 4 个字段,走 /// 对应客户端 `Net.VO.Mai2.UserCharacter`:只有 4 个字段,走
@@ -36,31 +38,19 @@ class UserCharacterBean {
useCount: useCount ?? this.useCount, 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) => factory UserCharacterBean.fromJson(Map<String, dynamic> json) =>
UserCharacterBean( UserCharacterBean(
characterId: _toInt(json['characterId']), characterId: jsonInt(json['characterId']),
level: _toInt(json['level']), level: jsonInt(json['level']),
awakening: _toInt(json['awakening']), awakening: jsonInt(json['awakening']),
useCount: _toInt(json['useCount']), useCount: jsonInt(json['useCount']),
); );
static List<UserCharacterBean> listFromResponse(Map<String, dynamic> json) { static List<UserCharacterBean> listFromResponse(Map<String, dynamic> json) =>
final raw = json['userCharacterList']; jsonMapList(json['userCharacterList'])
if (raw is! List) return const [];
return raw
.whereType<Map<String, dynamic>>()
.map(UserCharacterBean.fromJson) .map(UserCharacterBean.fromJson)
.toList(); .toList();
} }
}
/// `UserDetail.charaSlot` 固定 5 个槽,槽 0 是队长 /// `UserDetail.charaSlot` 固定 5 个槽,槽 0 是队长
/// (客户端 CharactorSlotController.cs:27 对 i==0 用 leaderCharaSlotPrefab)。 /// (客户端 CharactorSlotController.cs:27 对 i==0 用 leaderCharaSlotPrefab)。
+42 -58
View File
@@ -1,3 +1,5 @@
import '../core/json_value.dart';
class UserDataBean { class UserDataBean {
final int userId; final int userId;
final String userName; final String userName;
@@ -84,67 +86,49 @@ class UserDataBean {
}); });
factory UserDataBean.fromJson(Map<String, dynamic> json) { factory UserDataBean.fromJson(Map<String, dynamic> json) {
final ud = (json['userData'] as Map<String, dynamic>?) ?? const {}; final ud = jsonMap(json['userData']);
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() ?? '';
return UserDataBean( return UserDataBean(
userId: toInt(json['userId']), userId: jsonInt(json['userId']),
userName: toStr(ud['userName']), userName: jsonString(ud['userName']),
friendCode: ud['friendCode']?.toString(), friendCode: ud['friendCode']?.toString(),
isNetMember: toBool(ud['isNetMember']), isNetMember: jsonBool(ud['isNetMember']),
point: toInt(ud['point']), point: jsonInt(ud['point']),
totalPoint: toInt(ud['totalPoint']), totalPoint: jsonInt(ud['totalPoint']),
playerRating: toInt(ud['playerRating']), playerRating: jsonInt(ud['playerRating']),
playerOldRating: toInt(ud['playerOldRating']), playerOldRating: jsonInt(ud['playerOldRating']),
playerNewRating: toInt(ud['playerNewRating']), playerNewRating: jsonInt(ud['playerNewRating']),
highestRating: toInt(ud['highestRating']), highestRating: jsonInt(ud['highestRating']),
musicRating: toInt(ud['musicRating']), musicRating: jsonInt(ud['musicRating']),
gradeRank: toInt(ud['gradeRank']), gradeRank: jsonInt(ud['gradeRank']),
courseRank: toInt(ud['courseRank']), courseRank: jsonInt(ud['courseRank']),
classRank: toInt(ud['classRank']), classRank: jsonInt(ud['classRank']),
nameplateId: toInt(ud['nameplateId']), nameplateId: jsonInt(ud['nameplateId']),
frameId: toInt(ud['frameId']), frameId: jsonInt(ud['frameId']),
iconId: toInt(ud['iconId']), iconId: jsonInt(ud['iconId']),
trophyId: toInt(ud['trophyId']), trophyId: jsonInt(ud['trophyId']),
plateId: toInt(ud['plateId']), plateId: jsonInt(ud['plateId']),
titleId: toInt(ud['titleId']), titleId: jsonInt(ud['titleId']),
partnerId: toInt(ud['partnerId']), partnerId: jsonInt(ud['partnerId']),
playCount: toInt(ud['playCount']), playCount: jsonInt(ud['playCount']),
currentPlayCount: toInt(ud['currentPlayCount']), currentPlayCount: jsonInt(ud['currentPlayCount']),
totalSync: toInt(ud['totalSync']), totalSync: jsonInt(ud['totalSync']),
totalAchievement: toInt(ud['totalAchievement']), totalAchievement: jsonInt(ud['totalAchievement']),
totalDeluxscore: toInt(ud['totalDeluxscore']), totalDeluxscore: jsonInt(ud['totalDeluxscore']),
totalAwake: toInt(ud['totalAwake']), totalAwake: jsonInt(ud['totalAwake']),
lastGameId: toStr(ud['lastGameId']), lastGameId: jsonString(ud['lastGameId']),
lastRomVersion: toStr(ud['lastRomVersion']), lastRomVersion: jsonString(ud['lastRomVersion']),
lastDataVersion: toStr(ud['lastDataVersion']), lastDataVersion: jsonString(ud['lastDataVersion']),
lastLoginDate: toStr(ud['lastLoginDate']), lastLoginDate: jsonString(ud['lastLoginDate']),
lastPlayDate: toStr(ud['lastPlayDate']), lastPlayDate: jsonString(ud['lastPlayDate']),
firstGameId: toStr(ud['firstGameId']), firstGameId: jsonString(ud['firstGameId']),
firstRomVersion: toStr(ud['firstRomVersion']), firstRomVersion: jsonString(ud['firstRomVersion']),
firstDataVersion: toStr(ud['firstDataVersion']), firstDataVersion: jsonString(ud['firstDataVersion']),
firstPlayDate: toStr(ud['firstPlayDate']), firstPlayDate: jsonString(ud['firstPlayDate']),
lastRegionName: toStr(ud['lastRegionName']), lastRegionName: jsonString(ud['lastRegionName']),
lastRegionId: toInt(ud['lastRegionId']), lastRegionId: jsonInt(ud['lastRegionId']),
dailyBonusDate: toStr(ud['dailyBonusDate']), dailyBonusDate: jsonString(ud['dailyBonusDate']),
banState: toInt(json['banState']), banState: jsonInt(json['banState']),
); );
} }
} }
+18 -35
View File
@@ -1,3 +1,5 @@
import '../core/json_value.dart';
/// `Net.VO.Mai2.UserKaleidxScope`(万花筒 / 宿命之门),走 /// `Net.VO.Mai2.UserKaleidxScope`(万花筒 / 宿命之门),走
/// `upsertUserAll.userKaleidxScopeList`,主键是 `gateId`。 /// `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) => factory UserKaleidxScopeBean.fromJson(Map<String, dynamic> json) =>
UserKaleidxScopeBean( UserKaleidxScopeBean(
gateId: _toInt(json['gateId']), gateId: jsonInt(json['gateId']),
isGateFound: _toBool(json['isGateFound']), isGateFound: jsonBool(json['isGateFound']),
isKeyFound: _toBool(json['isKeyFound']), isKeyFound: jsonBool(json['isKeyFound']),
isClear: _toBool(json['isClear']), isClear: jsonBool(json['isClear']),
totalRestLife: _toInt(json['totalRestLife']), totalRestLife: jsonInt(json['totalRestLife']),
totalAchievement: _toInt(json['totalAchievement']), totalAchievement: jsonInt(json['totalAchievement']),
totalDeluxscore: _toInt(json['totalDeluxscore']), totalDeluxscore: jsonInt(json['totalDeluxscore']),
bestAchievement: _toInt(json['bestAchievement']), bestAchievement: jsonInt(json['bestAchievement']),
bestDeluxscore: _toInt(json['bestDeluxscore']), bestDeluxscore: jsonInt(json['bestDeluxscore']),
bestAchievementDate: json['bestAchievementDate']?.toString() ?? '', bestAchievementDate: jsonString(json['bestAchievementDate']),
bestDeluxscoreDate: json['bestDeluxscoreDate']?.toString() ?? '', bestDeluxscoreDate: jsonString(json['bestDeluxscoreDate']),
playCount: _toInt(json['playCount']), playCount: jsonInt(json['playCount']),
clearDate: json['clearDate']?.toString() ?? '', clearDate: jsonString(json['clearDate']),
lastPlayDate: json['lastPlayDate']?.toString() ?? '', lastPlayDate: jsonString(json['lastPlayDate']),
isInfoWatched: _toBool(json['isInfoWatched']), isInfoWatched: jsonBool(json['isInfoWatched']),
); );
static List<UserKaleidxScopeBean> listFromResponse( static List<UserKaleidxScopeBean> listFromResponse(
Map<String, dynamic> json, Map<String, dynamic> json,
) { ) => jsonMapList(json['userKaleidxScopeList'])
final raw = json['userKaleidxScopeList'];
if (raw is! List) return const [];
return raw
.whereType<Map<String, dynamic>>()
.map(UserKaleidxScopeBean.fromJson) .map(UserKaleidxScopeBean.fromJson)
.toList(); .toList();
} }
}
+23 -41
View File
@@ -1,3 +1,5 @@
import '../core/json_value.dart';
class UserPreviewDataBean { class UserPreviewDataBean {
final int userId; final int userId;
final String userName; final String userName;
@@ -49,48 +51,28 @@ class UserPreviewDataBean {
}); });
factory UserPreviewDataBean.fromJson(Map<String, dynamic> json) { 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( return UserPreviewDataBean(
userId: toInt(json['userId']), userId: jsonInt(json['userId']),
userName: toStr(json['userName']), userName: jsonString(json['userName']),
isLogin: toBool(json['isLogin']), isLogin: jsonBool(json['isLogin']),
loginId: toInt(json['loginId']), loginId: jsonInt(json['loginId']),
lastGameId: toStr(json['lastGameId']), lastGameId: jsonString(json['lastGameId']),
lastRomVersion: toStr(json['lastRomVersion']), lastRomVersion: jsonString(json['lastRomVersion']),
lastDataVersion: toStr(json['lastDataVersion']), lastDataVersion: jsonString(json['lastDataVersion']),
lastLoginDate: toStr(json['lastLoginDate']), lastLoginDate: jsonString(json['lastLoginDate']),
lastPlayDate: toStr(json['lastPlayDate']), lastPlayDate: jsonString(json['lastPlayDate']),
playerRating: toInt(json['playerRating']), playerRating: jsonInt(json['playerRating']),
nameplateId: toInt(json['nameplateId']), nameplateId: jsonInt(json['nameplateId']),
iconId: toInt(json['iconId']), iconId: jsonInt(json['iconId']),
trophyId: toInt(json['trophyId']), trophyId: jsonInt(json['trophyId']),
isNetMember: toBool(json['isNetMember']), isNetMember: jsonBool(json['isNetMember']),
isInherit: toBool(json['isInherit']), isInherit: jsonBool(json['isInherit']),
totalAwake: toInt(json['totalAwake']), totalAwake: jsonInt(json['totalAwake']),
dispRate: toInt(json['dispRate']), dispRate: jsonInt(json['dispRate']),
dailyBonusDate: toStr(json['dailyBonusDate']), dailyBonusDate: jsonString(json['dailyBonusDate']),
headPhoneVolume: (json['headPhoneVolume'] is String) headPhoneVolume: jsonInt(json['headPhoneVolume']),
? (int.tryParse(json['headPhoneVolume']) ?? 0) banState: jsonInt(json['banState']),
: toInt(json['headPhoneVolume']), errorId: jsonInt(json['errorId']),
banState: toInt(json['banState']),
errorId: toInt(json['errorId']),
); );
} }
} }
+12 -32
View File
@@ -1,3 +1,5 @@
import '../core/json_value.dart';
/// GetUserRatingApi 的 `userRating` 结构,移植自 Empurple 的 `UserRatingData`。 /// GetUserRatingApi 的 `userRating` 结构,移植自 Empurple 的 `UserRatingData`。
/// ///
/// 服务端已按 RA 排好 BEST 35 / BEST 15,客户端不再重排。 /// 服务端已按 RA 排好 BEST 35 / BEST 15,客户端不再重排。
@@ -43,21 +45,13 @@ class UserRatingItemBean {
MusicLevel get musicLevel => MusicLevel.fromInt(level); MusicLevel get musicLevel => MusicLevel.fromInt(level);
factory UserRatingItemBean.fromJson(Map<String, dynamic> json) { 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( return UserRatingItemBean(
musicId: toInt(json['musicId']), musicId: jsonInt(json['musicId']),
level: toInt(json['level']), level: jsonInt(json['level']),
achievement: toInt(json['achievement']), achievement: jsonInt(json['achievement']),
comboStatus: toInt(json['comboStatus']), comboStatus: jsonInt(json['comboStatus']),
syncStatus: toInt(json['syncStatus']), syncStatus: jsonInt(json['syncStatus']),
romVersion: toInt(json['romVersion']), romVersion: jsonInt(json['romVersion']),
); );
} }
} }
@@ -73,28 +67,14 @@ class UserRatingBean {
required this.newRatingList, required this.newRatingList,
}); });
static List<UserRatingItemBean> _parseList(dynamic raw) { static List<UserRatingItemBean> _parseList(dynamic raw) =>
if (raw is! List) return const []; jsonMapList(raw).map(UserRatingItemBean.fromJson).toList();
return raw
.whereType<Map<String, dynamic>>()
.map(UserRatingItemBean.fromJson)
.toList();
}
factory UserRatingBean.fromJson(Map<String, dynamic> json) { factory UserRatingBean.fromJson(Map<String, dynamic> json) {
int toInt(dynamic v) { final userRating = jsonMap(json['userRating']);
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 {};
return UserRatingBean( return UserRatingBean(
userId: toInt(json['userId']), userId: jsonInt(json['userId']),
ratingList: _parseList(userRating['ratingList']), ratingList: _parseList(userRating['ratingList']),
newRatingList: _parseList(userRating['newRatingList']), newRatingList: _parseList(userRating['newRatingList']),
); );
+6 -5
View File
@@ -6,6 +6,7 @@ import 'package:flutter/rendering.dart';
import '../config/responsive.dart'; import '../config/responsive.dart';
import '../config/strings.dart'; import '../config/strings.dart';
import '../config/title_server_config.dart'; import '../config/title_server_config.dart';
import '../models/session_model.dart';
import '../models/user_rating.dart'; import '../models/user_rating.dart';
import '../services/music_data_service.dart'; import '../services/music_data_service.dart';
import '../services/png_export_service.dart'; import '../services/png_export_service.dart';
@@ -56,7 +57,7 @@ class _Best50PageState extends State<Best50Page> {
@override @override
void didUpdateWidget(Best50Page oldWidget) { void didUpdateWidget(Best50Page oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
// 本页在 IndexedStack 里会先于 preview/登录请求被构建,cookies 到位后补一次加载。 // 用户可能在 preview/登录请求完成前就切到本页,cookies 到位后再补一次加载。
if (oldWidget.cookies != widget.cookies && if (oldWidget.cookies != widget.cookies &&
(_rating == null || _error != null)) { (_rating == null || _error != null)) {
_bootstrap(); _bootstrap();
@@ -75,7 +76,9 @@ class _Best50PageState extends State<Best50Page> {
}); });
try { try {
final service = TitleApiService.fromHolder(cookies: widget.cookies)!; final service = SessionModel.instance.createTitleService(
cookies: widget.cookies,
)!;
final rating = await service.getUserRatingTyped(widget.userId); final rating = await service.getUserRatingTyped(widget.userId);
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
@@ -87,10 +90,8 @@ class _Best50PageState extends State<Best50Page> {
await MusicDataService.instance.load(); await MusicDataService.instance.load();
if (!mounted) return; if (!mounted) return;
setState(() => _musicReady = true); setState(() => _musicReady = true);
} on TitleApiException catch (e) {
_fail(e.message);
} catch (e) { } catch (e) {
_fail('$e'); _fail(describeApiError(e));
} }
} }
+93 -179
View File
@@ -1,10 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../config/responsive.dart';
import '../config/strings.dart'; import '../config/strings.dart';
import '../config/title_server_config.dart'; import '../config/title_server_config.dart';
import '../widgets/app_notice.dart';
import '../widgets/cooldown_mixin.dart'; import '../widgets/cooldown_mixin.dart';
import '../widgets/risk/risk_widgets.dart';
import 'collectibles_page.dart'; import 'collectibles_page.dart';
import 'kaleidx_scope_page.dart'; import 'kaleidx_scope_page.dart';
import 'map_traverse_page.dart'; import 'map_traverse_page.dart';
@@ -48,10 +47,19 @@ class _HighRiskFeaturePageState extends State<HighRiskFeaturePage>
@override @override
int? get cooldownLoginDateTime => widget.loginDateTime; int? get cooldownLoginDateTime => widget.loginDateTime;
void _openUnlockMusic() { void _push(Widget page) {
Navigator.of(context).push( Navigator.of(context).push(MaterialPageRoute(builder: (_) => page));
MaterialPageRoute( }
builder: (_) => UnlockMusicPage(
/// 中转页只是导航入口,七张卡片的差异只有图标、文案和目标页,
/// 用一张表驱动,避免七份几乎相同的 `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, userId: widget.userId,
cookies: widget.cookies, cookies: widget.cookies,
loginDateTime: widget.loginDateTime, loginDateTime: widget.loginDateTime,
@@ -60,13 +68,11 @@ class _HighRiskFeaturePageState extends State<HighRiskFeaturePage>
onExitToTitle: widget.onExitToTitle, onExitToTitle: widget.onExitToTitle,
), ),
), ),
); (
} icon: Icons.card_giftcard,
title: AppStrings.collectiblesFeatureTitle,
void _openCollectibles() { desc: AppStrings.collectiblesFeatureDesc,
Navigator.of(context).push( page: CollectiblesPage(
MaterialPageRoute(
builder: (_) => CollectiblesPage(
userId: widget.userId, userId: widget.userId,
cookies: widget.cookies, cookies: widget.cookies,
loginDateTime: widget.loginDateTime, loginDateTime: widget.loginDateTime,
@@ -75,13 +81,11 @@ class _HighRiskFeaturePageState extends State<HighRiskFeaturePage>
onExitToTitle: widget.onExitToTitle, onExitToTitle: widget.onExitToTitle,
), ),
), ),
); (
} icon: Icons.workspace_premium_outlined,
title: AppStrings.travelPartnerFeatureTitle,
void _openTravelPartner() { desc: AppStrings.travelPartnerFeatureDesc,
Navigator.of(context).push( page: TravelPartnerPage(
MaterialPageRoute(
builder: (_) => TravelPartnerPage(
userId: widget.userId, userId: widget.userId,
cookies: widget.cookies, cookies: widget.cookies,
loginDateTime: widget.loginDateTime, loginDateTime: widget.loginDateTime,
@@ -89,28 +93,37 @@ class _HighRiskFeaturePageState extends State<HighRiskFeaturePage>
onExitToTitle: widget.onExitToTitle, onExitToTitle: widget.onExitToTitle,
), ),
), ),
); (
} icon: Icons.speed,
title: AppStrings.ratingFeatureTitle,
void _openNumberPatch(NumberPatchMode mode) { desc: AppStrings.ratingFeatureDesc,
Navigator.of(context).push( page: NumberPatchPage(
MaterialPageRoute(
builder: (_) => NumberPatchPage(
userId: widget.userId, userId: widget.userId,
cookies: widget.cookies, cookies: widget.cookies,
loginDateTime: widget.loginDateTime, loginDateTime: widget.loginDateTime,
loginId: widget.loginId, loginId: widget.loginId,
onExitToTitle: widget.onExitToTitle, onExitToTitle: widget.onExitToTitle,
mode: mode, mode: NumberPatchMode.rating,
), ),
), ),
); (
} icon: Icons.commute,
title: AppStrings.maiMileFeatureTitle,
void _openMapTraverse() { desc: AppStrings.maiMileFeatureDesc,
Navigator.of(context).push( page: NumberPatchPage(
MaterialPageRoute( userId: widget.userId,
builder: (_) => MapTraversePage( 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, userId: widget.userId,
cookies: widget.cookies, cookies: widget.cookies,
loginDateTime: widget.loginDateTime, loginDateTime: widget.loginDateTime,
@@ -118,13 +131,11 @@ class _HighRiskFeaturePageState extends State<HighRiskFeaturePage>
onExitToTitle: widget.onExitToTitle, onExitToTitle: widget.onExitToTitle,
), ),
), ),
); (
} icon: Icons.science_outlined,
title: AppStrings.kaleidxFeatureTitle,
void _openKaleidxScope() { desc: AppStrings.kaleidxFeatureDesc,
Navigator.of(context).push( page: KaleidxScopePage(
MaterialPageRoute(
builder: (_) => KaleidxScopePage(
userId: widget.userId, userId: widget.userId,
cookies: widget.cookies, cookies: widget.cookies,
loginDateTime: widget.loginDateTime, loginDateTime: widget.loginDateTime,
@@ -132,167 +143,70 @@ class _HighRiskFeaturePageState extends State<HighRiskFeaturePage>
onExitToTitle: widget.onExitToTitle, onExitToTitle: widget.onExitToTitle,
), ),
), ),
); ];
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context);
if (!TitleServerConfigHolder().isConfigured) { if (!TitleServerConfigHolder().isConfigured) {
return Center( return const RiskConfigMissingView(
child: Padding( message: AppStrings.ticketNotConfigured,
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.settings_ethernet,
size: 48, color: theme.colorScheme.primary),
const SizedBox(height: 16),
Text(AppStrings.ticketNotConfigured,
style: theme.textTheme.titleMedium),
],
),
),
); );
} }
return SingleChildScrollView( final entries = _entries;
padding: const EdgeInsets.all(16), return RiskPageBody(
child: responsiveBody(
context,
child: Column(
children: [ children: [
if (!isLoggedIn) RiskStatusNotices(
AppNotice( isLoggedIn: isLoggedIn,
AppStrings.musicRiskHubNotLoggedIn, cooldownRemaining: cooldownRemaining,
error: true, notLoggedInText: AppStrings.musicRiskHubNotLoggedIn,
cooldownText: AppStrings.musicRiskHubCooldownNotice,
),
const RiskFeatureCard(
icon: Icons.warning_amber_rounded, icon: Icons.warning_amber_rounded,
title: AppStrings.musicRiskHubTitle,
description: AppStrings.musicRiskHubDesc,
danger: true,
), ),
if (isLoggedIn && cooldownRemaining > 0) for (var i = 0; i < entries.length; i++) ...[
AppNotice(
AppStrings.musicRiskHubCooldownNotice(cooldownRemaining),
icon: Icons.timer_outlined,
),
_buildDescCard(theme),
const SizedBox(height: 12), const SizedBox(height: 12),
_buildNavCard( _NavCard(
theme, icon: entries[i].icon,
icon: Icons.lock_open, title: entries[i].title,
title: AppStrings.unlockFeatureTitle, desc: entries[i].desc,
desc: AppStrings.unlockFeatureDesc, onTap: () => _push(entries[i].page),
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,
), ),
], ],
), ],
),
); );
} }
}
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( return Card(
elevation: 0, elevation: 0,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
side: BorderSide( 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( child: InkWell(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
onTap: onTap, 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),
),
],
),
),
);
}
}
+110 -1039
View File
File diff suppressed because it is too large. Load diff
+73 -194
View File
@@ -1,15 +1,16 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../config/responsive.dart';
import '../config/strings.dart'; import '../config/strings.dart';
import '../config/title_server_config.dart'; import '../models/session_model.dart';
import '../models/user_kaleidx_scope.dart'; import '../models/user_kaleidx_scope.dart';
import '../services/title_api_service.dart'; import '../services/title_api_service.dart';
import '../services/user_all_payload_builder.dart'; import '../services/user_all_payload_builder.dart';
import '../widgets/app_card.dart'; import '../widgets/app_card.dart';
import '../widgets/app_notice.dart'; import '../widgets/app_notice.dart';
import '../widgets/cooldown_mixin.dart'; import '../widgets/cooldown_mixin.dart';
import '../widgets/risk/risk_run_mixin.dart';
import '../widgets/risk/risk_widgets.dart';
import '../widgets/step_progress.dart'; import '../widgets/step_progress.dart';
/// 万花筒专区:发现新的宿命之门、获取门的钥匙、设置门的通关状态。 /// 万花筒专区:发现新的宿命之门、获取门的钥匙、设置门的通关状态。
@@ -41,7 +42,7 @@ class KaleidxScopePage extends StatefulWidget {
enum _ClearChoice { keep, cleared, uncleared } enum _ClearChoice { keep, cleared, uncleared }
class _KaleidxScopePageState extends State<KaleidxScopePage> class _KaleidxScopePageState extends State<KaleidxScopePage>
with CooldownMixin<KaleidxScopePage> { with CooldownMixin<KaleidxScopePage>, RiskRunMixin<KaleidxScopePage> {
final _gateIdController = TextEditingController(); final _gateIdController = TextEditingController();
final List<int> _pending = []; final List<int> _pending = [];
@@ -52,12 +53,6 @@ class _KaleidxScopePageState extends State<KaleidxScopePage>
Map<String, Map<String, dynamic>> _userAllData = const {}; Map<String, Map<String, dynamic>> _userAllData = const {};
List<UserKaleidxScopeBean> _serverScopes = const []; List<UserKaleidxScopeBean> _serverScopes = const [];
bool _loading = false;
bool _running = false;
bool _autoLogout = true;
RiskStep _step = RiskStep.idle;
String _stepMessage = '';
@override @override
int? get cooldownLoginDateTime => widget.loginDateTime; int? get cooldownLoginDateTime => widget.loginDateTime;
@@ -120,11 +115,13 @@ class _KaleidxScopePageState extends State<KaleidxScopePage>
} }
Future<void> _fetchData() async { Future<void> _fetchData() async {
if (!TitleServerConfigHolder().isConfigured) return; final service = SessionModel.instance.createTitleService(
setState(() => _loading = true); cookies: widget.cookies,
);
if (service == null) return;
setRiskFetching(true);
try { try {
final service = TitleApiService.fromHolder(cookies: widget.cookies)!;
final results = await Future.wait([ final results = await Future.wait([
service.fetchUserAllData(widget.userId), service.fetchUserAllData(widget.userId),
service.getUserKaleidxScopes(widget.userId), service.getUserKaleidxScopes(widget.userId),
@@ -133,28 +130,25 @@ class _KaleidxScopePageState extends State<KaleidxScopePage>
setState(() { setState(() {
_userAllData = results[0] as Map<String, Map<String, dynamic>>; _userAllData = results[0] as Map<String, Map<String, dynamic>>;
_serverScopes = results[1] as List<UserKaleidxScopeBean>; _serverScopes = results[1] as List<UserKaleidxScopeBean>;
_loading = false; riskFetching = false;
}); });
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
setState(() => _loading = false); setState(() => riskFetching = false);
_snack('${AppStrings.loadFailed}: $e'); _snack('${AppStrings.loadFailed}: ${describeApiError(e)}');
} }
} }
Future<void> _run() async { Future<void> _run() async {
if (!TitleServerConfigHolder().isConfigured) { final guard = riskPreconditionError(
_snack(AppStrings.ticketNotConfigured); loginDateTime: widget.loginDateTime,
return; loginId: widget.loginId,
} cooldownRemaining: cooldownRemaining,
final loginDateTime = widget.loginDateTime; notLoggedInMessage: AppStrings.kaleidxNotLoggedIn,
final loginId = widget.loginId; cooldownNotice: AppStrings.kaleidxCooldownNotice,
if (loginDateTime == null || loginId == null) { );
_snack(AppStrings.kaleidxNotLoggedIn); if (guard != null) {
return; _snack(guard);
}
if (cooldownRemaining > 0) {
_snack(AppStrings.kaleidxCooldownNotice(cooldownRemaining));
return; return;
} }
if (_pending.isEmpty) { if (_pending.isEmpty) {
@@ -166,15 +160,19 @@ class _KaleidxScopePageState extends State<KaleidxScopePage>
return; return;
} }
setState(() => _running = true); final loginDateTime = widget.loginDateTime!;
final loginId = widget.loginId!;
setRiskRunning(true);
try { try {
final service = TitleApiService.fromHolder(cookies: widget.cookies)!; final service = SessionModel.instance.createTitleService(
cookies: widget.cookies,
)!;
final builder = UserAllPayloadBuilder(service.config); final builder = UserAllPayloadBuilder(service.config);
var data = _userAllData; var data = _userAllData;
if (data.isEmpty || _serverScopes.isEmpty) { if (data.isEmpty || _serverScopes.isEmpty) {
_updateStep(RiskStep.fetchData); updateRiskStep(RiskStep.fetchData);
final results = await Future.wait([ final results = await Future.wait([
service.fetchUserAllData(widget.userId), service.fetchUserAllData(widget.userId),
service.getUserKaleidxScopes(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( final packet = builder.build(
userId: widget.userId, userId: widget.userId,
loginId: loginId, loginId: loginId,
@@ -209,20 +207,12 @@ class _KaleidxScopePageState extends State<KaleidxScopePage>
); );
await service.upsertUserAll(packet, widget.userId); await service.upsertUserAll(packet, widget.userId);
_updateStep(RiskStep.complete, await _verify(service)); updateRiskStep(RiskStep.complete, await _verify(service));
await autoLogoutAndExit(widget.onExitToTitle);
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);
} catch (e) { } catch (e) {
_updateStep(RiskStep.failed, '$e'); reportRiskError(e);
} finally { } finally {
if (mounted) setState(() => _running = false); setRiskRunning(false);
} }
} }
@@ -232,7 +222,7 @@ class _KaleidxScopePageState extends State<KaleidxScopePage>
try { try {
rows = await service.getUserKaleidxScopes(widget.userId); rows = await service.getUserKaleidxScopes(widget.userId);
} catch (e) { } catch (e) {
return AppStrings.kaleidxVerifyFailed('$e'); return AppStrings.kaleidxVerifyFailed(describeApiError(e));
} }
if (mounted) setState(() => _serverScopes = rows); if (mounted) setState(() => _serverScopes = rows);
@@ -246,106 +236,73 @@ class _KaleidxScopePageState extends State<KaleidxScopePage>
return AppStrings.kaleidxVerified(saved.length); return AppStrings.kaleidxVerified(saved.length);
} }
void _updateStep(RiskStep step, [String? message]) {
if (!mounted) return;
setState(() {
_step = step;
_stepMessage = message ?? '';
});
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text(AppStrings.kaleidxFeatureTitle)), appBar: AppBar(title: const Text(AppStrings.kaleidxFeatureTitle)),
body: SingleChildScrollView( body: RiskPageBody(
padding: const EdgeInsets.all(16),
child: responsiveBody(
context,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
if (!isLoggedIn) RiskStatusNotices(
const AppNotice(AppStrings.kaleidxNotLoggedIn, error: true), isLoggedIn: isLoggedIn,
if (isLoggedIn && cooldownRemaining > 0) cooldownRemaining: cooldownRemaining,
AppNotice( notLoggedInText: AppStrings.kaleidxNotLoggedIn,
AppStrings.kaleidxCooldownNotice(cooldownRemaining), cooldownText: AppStrings.kaleidxCooldownNotice,
), ),
AppCard( RiskFeatureCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SectionTitle(
icon: Icons.science_outlined, icon: Icons.science_outlined,
title: AppStrings.kaleidxFeatureTitle, title: AppStrings.kaleidxFeatureTitle,
), description: AppStrings.kaleidxFeatureDesc,
const SizedBox(height: 12),
Text(
AppStrings.kaleidxFeatureDesc,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
height: 1.5,
),
),
],
),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
_inputCard(theme), _inputCard(theme),
const SizedBox(height: 12), const SizedBox(height: 12),
_fetchCard(theme), RiskFetchCard(
const SizedBox(height: 12), hasData: _userAllData.isNotEmpty,
AutoLogoutToggle( statusText: _userAllData.isNotEmpty
value: _autoLogout, ? '${AppStrings.kaleidxFetched} (${_serverScopes.length})'
enabled: widget.onExitToTitle != null && !_running, : AppStrings.kaleidxNoData,
onChanged: (v) => setState(() => _autoLogout = v), loadingLabel: AppStrings.travelPartnerFetching,
actionLabel: AppStrings.kaleidxFetchData,
loading: riskFetching,
enabled: !riskRunning,
onFetch: _fetchData,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
_runButton(theme), AutoLogoutToggle(
if (_step != RiskStep.idle) ...[ 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), const SizedBox(height: 16),
StepProgressCard( StepProgressCard(
step: _step, step: riskStep,
message: _stepMessage, message: riskStepMessage,
steps: [ steps: [
(RiskStep.fetchData, AppStrings.kaleidxStepFetch), (RiskStep.fetchData, AppStrings.kaleidxStepFetch),
(RiskStep.upload, AppStrings.kaleidxStepUpload), (RiskStep.upload, AppStrings.kaleidxStepUpload),
if (_autoLogout) (RiskStep.logout, AppStrings.stepLogout), if (riskAutoLogout) (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(
children: [
Checkbox(
value: value,
onChanged: enabled ? (v) => onChanged(v ?? false) : null,
),
Expanded(child: Text(label)),
],
),
); );
} }
Widget _inputCard(ThemeData theme) { Widget _inputCard(ThemeData theme) {
final enabled = !_running; final enabled = !riskRunning;
return AppCard( return AppCard(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
@@ -376,13 +333,13 @@ class _KaleidxScopePageState extends State<KaleidxScopePage>
), ),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
_checkRow( RiskCheckRow(
label: AppStrings.kaleidxActionDiscover, label: AppStrings.kaleidxActionDiscover,
value: _discover, value: _discover,
enabled: enabled, enabled: enabled,
onChanged: (v) => setState(() => _discover = v), onChanged: (v) => setState(() => _discover = v),
), ),
_checkRow( RiskCheckRow(
label: AppStrings.kaleidxActionKey, label: AppStrings.kaleidxActionKey,
value: _giveKey, value: _giveKey,
enabled: enabled, enabled: enabled,
@@ -466,82 +423,4 @@ class _KaleidxScopePageState extends State<KaleidxScopePage>
row.isClear, 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 '../models/session_share.dart';
import '../services/file_picker_service.dart'; import '../services/file_picker_service.dart';
import '../services/qr_service.dart'; import '../services/qr_service.dart';
import '../services/title_api_service.dart';
import '../widgets/app_notice.dart'; import '../widgets/app_notice.dart';
import 'home_page.dart'; import 'home_page.dart';
import 'settings_page.dart'; import 'settings_page.dart';
@@ -22,7 +23,6 @@ class LoginPage extends StatefulWidget {
class _LoginPageState extends State<LoginPage> { class _LoginPageState extends State<LoginPage> {
final _qrController = TextEditingController(); final _qrController = TextEditingController();
String _qrCode = '';
String _rawInput = ''; String _rawInput = '';
SessionShare? _share; SessionShare? _share;
bool _loading = false; bool _loading = false;
@@ -38,7 +38,6 @@ class _LoginPageState extends State<LoginPage> {
final text = value.trim(); final text = value.trim();
setState(() { setState(() {
_rawInput = text; _rawInput = text;
_qrCode = SessionModel.extractQRCode(text);
_share = SessionShare.tryDecode(text); _share = SessionShare.tryDecode(text);
}); });
} }
@@ -51,12 +50,9 @@ class _LoginPageState extends State<LoginPage> {
final result = await decodeQRFromBytes(bytes); final result = await decodeQRFromBytes(bytes);
if (result != null && result.isNotEmpty) { if (result != null && result.isNotEmpty) {
if (!mounted) return;
_qrController.text = result; _qrController.text = result;
setState(() { _onQRContentChanged(result);
_rawInput = result.trim();
_qrCode = SessionModel.extractQRCode(result);
_share = SessionShare.tryDecode(result);
});
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text(AppStrings.qrScanSuccess)), const SnackBar(content: Text(AppStrings.qrScanSuccess)),
@@ -79,18 +75,11 @@ class _LoginPageState extends State<LoginPage> {
} }
Future<void> _onLogin() async { Future<void> _onLogin() async {
if (_qrCode.isEmpty) return; if (_rawInput.isEmpty) return;
// 令牌是粘贴内容的后 64 位,前缀可能在截取之后就没了,所以要拿原文判断;
// 认不出来时必须给提示,否则点了按钮一声不响,像是坏了。
if (!_rawInput.contains('SGWCMAID')) {
context.showSnack(AppStrings.qrTokenUnrecognized);
return;
}
setState(() => _loading = true); setState(() => _loading = true);
try { try {
final result = await SessionModel.instance.loginWithQr(_qrCode); final result = await SessionModel.instance.loginWithQr(_rawInput);
if (!mounted) return; if (!mounted) return;
@@ -105,18 +94,22 @@ class _LoginPageState extends State<LoginPage> {
), ),
); );
} else { } else {
ScaffoldMessenger.of(context).showSnackBar( context.showSnack(
SnackBar( '${AppStrings.loginFailed}:'
content: Text( '${result.message ?? 'errorID=${result.errorId}'}',
'${AppStrings.loginFailed} (errorID: ${result.errorId})',
),
),
); );
} }
} on FormatException catch (e) {
// 令牌格式不对 / 没配 Keychip:登录请求根本没发出去,要说清楚是哪一种。
if (mounted) {
context.showSnack('${AppStrings.qrTokenUnrecognized}:${e.message}');
}
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${AppStrings.requestFailed}: $e')), SnackBar(
content: Text('${AppStrings.requestFailed}: ${describeApiError(e)}'),
),
); );
} }
} finally { } finally {
@@ -155,7 +148,9 @@ class _LoginPageState extends State<LoginPage> {
); );
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
context.showSnack('${AppStrings.sessionRestoreFailed}: $e'); context.showSnack(
'${AppStrings.sessionRestoreFailed}: ${describeApiError(e)}',
);
} }
} finally { } finally {
if (mounted) setState(() => _loading = false); if (mounted) setState(() => _loading = false);
@@ -285,7 +280,7 @@ class _LoginPageState extends State<LoginPage> {
child: FilledButton.icon( child: FilledButton.icon(
onPressed: isRestore onPressed: isRestore
? (_loading ? null : _onRestoreSession) ? (_loading ? null : _onRestoreSession)
: (_qrCode.isNotEmpty && !_loading) : (_rawInput.isNotEmpty && !_loading)
? _onLogin ? _onLogin
: null, : null,
icon: _loading icon: _loading
+77 -172
View File
@@ -1,14 +1,16 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../config/responsive.dart';
import '../config/strings.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/title_api_service.dart';
import '../services/user_all_payload_builder.dart'; import '../services/user_all_payload_builder.dart';
import '../widgets/app_card.dart'; import '../widgets/app_card.dart';
import '../widgets/app_notice.dart'; import '../widgets/app_notice.dart';
import '../widgets/cooldown_mixin.dart'; import '../widgets/cooldown_mixin.dart';
import '../widgets/risk/risk_run_mixin.dart';
import '../widgets/risk/risk_widgets.dart';
import '../widgets/step_progress.dart'; import '../widgets/step_progress.dart';
/// 一键跑图:把选中的区域标记为已完成。 /// 一键跑图:把选中的区域标记为已完成。
@@ -43,7 +45,7 @@ class MapTraversePage extends StatefulWidget {
} }
class _MapTraversePageState extends State<MapTraversePage> class _MapTraversePageState extends State<MapTraversePage>
with CooldownMixin<MapTraversePage> { with CooldownMixin<MapTraversePage>, RiskRunMixin<MapTraversePage> {
final _mapIdController = TextEditingController(); final _mapIdController = TextEditingController();
final List<int> _pending = []; final List<int> _pending = [];
@@ -51,12 +53,6 @@ class _MapTraversePageState extends State<MapTraversePage>
Set<int>? _serverMapIds; Set<int>? _serverMapIds;
List<Map<String, dynamic>> _serverMaps = const []; List<Map<String, dynamic>> _serverMaps = const [];
bool _loading = false;
bool _running = false;
bool _autoLogout = true;
RiskStep _step = RiskStep.idle;
String _stepMessage = '';
@override @override
int? get cooldownLoginDateTime => widget.loginDateTime; int? get cooldownLoginDateTime => widget.loginDateTime;
@@ -87,17 +83,19 @@ class _MapTraversePageState extends State<MapTraversePage>
void _applyServerMaps(List<Map<String, dynamic>> rows) { void _applyServerMaps(List<Map<String, dynamic>> rows) {
_serverMaps = rows; _serverMaps = rows;
_serverMapIds = rows _serverMapIds = rows
.map((e) => (e['mapId'] as num?)?.toInt() ?? 0) .map((e) => jsonInt(e['mapId']))
.where((e) => e != 0) .where((e) => e != 0)
.toSet(); .toSet();
} }
Future<void> _fetchData() async { Future<void> _fetchData() async {
if (!TitleServerConfigHolder().isConfigured) return; final service = SessionModel.instance.createTitleService(
setState(() => _loading = true); cookies: widget.cookies,
);
if (service == null) return;
setRiskFetching(true);
try { try {
final service = TitleApiService.fromHolder(cookies: widget.cookies)!;
final results = await Future.wait([ final results = await Future.wait([
service.fetchUserAllData(widget.userId), service.fetchUserAllData(widget.userId),
service.getUserMaps(widget.userId), service.getUserMaps(widget.userId),
@@ -106,28 +104,25 @@ class _MapTraversePageState extends State<MapTraversePage>
setState(() { setState(() {
_userAllData = results[0] as Map<String, Map<String, dynamic>>; _userAllData = results[0] as Map<String, Map<String, dynamic>>;
_applyServerMaps(results[1] as List<Map<String, dynamic>>); _applyServerMaps(results[1] as List<Map<String, dynamic>>);
_loading = false; riskFetching = false;
}); });
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
setState(() => _loading = false); setState(() => riskFetching = false);
_snack('${AppStrings.loadFailed}: $e'); _snack('${AppStrings.loadFailed}: ${describeApiError(e)}');
} }
} }
Future<void> _run() async { Future<void> _run() async {
if (!TitleServerConfigHolder().isConfigured) { final guard = riskPreconditionError(
_snack(AppStrings.ticketNotConfigured); loginDateTime: widget.loginDateTime,
return; loginId: widget.loginId,
} cooldownRemaining: cooldownRemaining,
final loginDateTime = widget.loginDateTime; notLoggedInMessage: AppStrings.mapNotLoggedIn,
final loginId = widget.loginId; cooldownNotice: AppStrings.mapCooldownNotice,
if (loginDateTime == null || loginId == null) { );
_snack(AppStrings.mapNotLoggedIn); if (guard != null) {
return; _snack(guard);
}
if (cooldownRemaining > 0) {
_snack(AppStrings.mapCooldownNotice(cooldownRemaining));
return; return;
} }
if (_pending.isEmpty) { if (_pending.isEmpty) {
@@ -135,16 +130,20 @@ class _MapTraversePageState extends State<MapTraversePage>
return; return;
} }
setState(() => _running = true); final loginDateTime = widget.loginDateTime!;
final loginId = widget.loginId!;
setRiskRunning(true);
try { try {
final service = TitleApiService.fromHolder(cookies: widget.cookies)!; final service = SessionModel.instance.createTitleService(
cookies: widget.cookies,
)!;
final builder = UserAllPayloadBuilder(service.config); final builder = UserAllPayloadBuilder(service.config);
var data = _userAllData; var data = _userAllData;
var serverIds = _serverMapIds; var serverIds = _serverMapIds;
if (data.isEmpty || serverIds == null) { if (data.isEmpty || serverIds == null) {
_updateStep(RiskStep.fetchData); updateRiskStep(RiskStep.fetchData);
final results = await Future.wait([ final results = await Future.wait([
service.fetchUserAllData(widget.userId), service.fetchUserAllData(widget.userId),
service.getUserMaps(widget.userId), service.getUserMaps(widget.userId),
@@ -155,7 +154,7 @@ class _MapTraversePageState extends State<MapTraversePage>
serverIds = _serverMapIds!; serverIds = _serverMapIds!;
} }
_updateStep(RiskStep.upload); updateRiskStep(RiskStep.upload);
final packet = builder.build( final packet = builder.build(
userId: widget.userId, userId: widget.userId,
loginId: loginId, loginId: loginId,
@@ -175,20 +174,12 @@ class _MapTraversePageState extends State<MapTraversePage>
builder.applyMapPatch(packet, maps: maps, newFlags: flags); builder.applyMapPatch(packet, maps: maps, newFlags: flags);
await service.upsertUserAll(packet, widget.userId); await service.upsertUserAll(packet, widget.userId);
_updateStep(RiskStep.complete, await _verify(service)); updateRiskStep(RiskStep.complete, await _verify(service));
await autoLogoutAndExit(widget.onExitToTitle);
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);
} catch (e) { } catch (e) {
_updateStep(RiskStep.failed, '$e'); reportRiskError(e);
} finally { } finally {
if (mounted) setState(() => _running = false); setRiskRunning(false);
} }
} }
@@ -199,7 +190,7 @@ class _MapTraversePageState extends State<MapTraversePage>
try { try {
rows = await service.getUserMaps(widget.userId); rows = await service.getUserMaps(widget.userId);
} catch (e) { } catch (e) {
return AppStrings.mapVerifyFailed('$e'); return AppStrings.mapVerifyFailed(describeApiError(e));
} }
if (mounted) setState(() => _applyServerMaps(rows)); if (mounted) setState(() => _applyServerMaps(rows));
@@ -211,91 +202,83 @@ class _MapTraversePageState extends State<MapTraversePage>
return AppStrings.mapVerified(requested.length); return AppStrings.mapVerified(requested.length);
} }
void _updateStep(RiskStep step, [String? message]) {
if (!mounted) return;
setState(() {
_step = step;
_stepMessage = message ?? '';
});
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text(AppStrings.mapFeatureTitle)), appBar: AppBar(title: const Text(AppStrings.mapFeatureTitle)),
body: SingleChildScrollView( body: RiskPageBody(
padding: const EdgeInsets.all(16),
child: responsiveBody(
context,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
if (!isLoggedIn) RiskStatusNotices(
const AppNotice(AppStrings.mapNotLoggedIn, error: true), isLoggedIn: isLoggedIn,
if (isLoggedIn && cooldownRemaining > 0) cooldownRemaining: cooldownRemaining,
AppNotice(AppStrings.mapCooldownNotice(cooldownRemaining)), notLoggedInText: AppStrings.mapNotLoggedIn,
AppCard( cooldownText: AppStrings.mapCooldownNotice,
child: Column( ),
crossAxisAlignment: CrossAxisAlignment.start, RiskFeatureCard(
children: [
const SectionTitle(
icon: Icons.map_outlined, icon: Icons.map_outlined,
title: AppStrings.mapFeatureTitle, title: AppStrings.mapFeatureTitle,
), description: AppStrings.mapFeatureDesc,
const SizedBox(height: 12), child: Padding(
Text( padding: const EdgeInsets.only(top: 10),
AppStrings.mapFeatureDesc, child: Text(
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
height: 1.5,
),
),
const SizedBox(height: 10),
Text(
AppStrings.mapCollectiblesNotice, AppStrings.mapCollectiblesNotice,
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error, color: theme.colorScheme.error,
height: 1.5, height: 1.5,
), ),
), ),
],
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
_inputCard(theme), _inputCard(theme),
const SizedBox(height: 12), const SizedBox(height: 12),
_fetchCard(theme), RiskFetchCard(
const SizedBox(height: 12), hasData: _serverMapIds != null,
AutoLogoutToggle( statusText: _serverMapIds != null
value: _autoLogout, ? '${AppStrings.mapFetched} (${_serverMaps.length})'
enabled: widget.onExitToTitle != null && !_running, : AppStrings.mapNoData,
onChanged: (v) => setState(() => _autoLogout = v), loadingLabel: AppStrings.travelPartnerFetching,
actionLabel: AppStrings.mapFetchData,
loading: riskFetching,
enabled: !riskRunning,
onFetch: _fetchData,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
_runButton(theme), AutoLogoutToggle(
if (_step != RiskStep.idle) ...[ 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), const SizedBox(height: 16),
StepProgressCard( StepProgressCard(
step: _step, step: riskStep,
message: _stepMessage, message: riskStepMessage,
steps: [ steps: [
(RiskStep.fetchData, AppStrings.mapStepFetch), (RiskStep.fetchData, AppStrings.mapStepFetch),
(RiskStep.upload, AppStrings.mapStepUpload), (RiskStep.upload, AppStrings.mapStepUpload),
if (_autoLogout) (RiskStep.logout, AppStrings.stepLogout), if (riskAutoLogout) (RiskStep.logout, AppStrings.stepLogout),
], ],
), ),
], ],
], ],
), ),
),
),
); );
} }
Widget _inputCard(ThemeData theme) { Widget _inputCard(ThemeData theme) {
final enabled = !_running; final enabled = !riskRunning;
return AppCard( return AppCard(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, 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),
),
),
),
);
}
} }
+119 -623
View File
@@ -1,13 +1,17 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../config/responsive.dart';
import '../config/strings.dart'; import '../config/strings.dart';
import '../config/title_server_config.dart'; import '../config/title_server_config.dart';
import '../models/session_model.dart';
import '../services/title_api_service.dart'; import '../services/title_api_service.dart';
import '../services/user_all_payload_builder.dart'; import '../services/user_all_payload_builder.dart';
import '../widgets/app_card.dart'; import '../widgets/app_card.dart';
import '../widgets/app_notice.dart'; import '../widgets/app_notice.dart';
import '../widgets/cooldown_mixin.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'; import '../widgets/step_progress.dart';
/// Dart port of `eaquira/action/UnlockMusic.py`: /// Dart port of `eaquira/action/UnlockMusic.py`:
@@ -25,39 +29,6 @@ enum MusicRiskFeatureMode {
collectibles, 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 { class MusicRiskFeatureView extends StatefulWidget {
final int userId; final int userId;
final String? cookies; final String? cookies;
@@ -96,106 +67,42 @@ class MusicRiskFeatureView extends StatefulWidget {
} }
class _MusicRiskFeatureViewState extends State<MusicRiskFeatureView> class _MusicRiskFeatureViewState extends State<MusicRiskFeatureView>
with CooldownMixin<MusicRiskFeatureView> { with CooldownMixin<MusicRiskFeatureView>, RiskRunMixin<MusicRiskFeatureView> {
// ── 解锁选项 (UnlockMusic) ──
final _unlockMusicIdController = TextEditingController();
bool _unlockMusic = false;
bool _unlockMaster = false;
bool _unlockRemaster = false;
// ── 收藏品选项 (Collectibles) ──
final _itemIdController = TextEditingController();
int _itemKind = 3;
// ── 待提交列表:userItemList 可以一次带多行 ── // ── 待提交列表:userItemList 可以一次带多行 ──
// 真客户端的 ExportUserItems() 就是把 Plate/Title/Icon/Partner/Frame/Ticket 与 // 真客户端的 ExportUserItems() 就是把 Plate/Title/Icon/Partner/Frame/Ticket 与
// MusicUnlock/Master/Remaster 各列表拼成多行 UserItem,BuildListData 再只发增量、 // MusicUnlock/Master/Remaster 各列表拼成多行 UserItem,BuildListData 再只发增量、
// isNewItemList 一行一个字符。所以多行同类/异类混发是客户端自己的做法。 // isNewItemList 一行一个字符。所以多行同类/异类混发是客户端自己的做法。
final List<_PendingItem> _pendingItems = []; final List<PendingItem> _pendingItems = [];
final List<_PendingMusic> _pendingMusics = []; final List<PendingMusic> _pendingMusics = [];
// ── 运行状态 ──
Map<String, Map<String, dynamic>>? _userAllData; Map<String, Map<String, dynamic>>? _userAllData;
bool _fetching = false;
String? _fetchError; String? _fetchError;
bool _running = false;
bool _autoLogout = true;
RiskStep _step = RiskStep.idle;
String _stepMessage = '';
String? _error;
bool get _isUnlock => widget.mode == MusicRiskFeatureMode.unlockMusic; 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 @override
int? get cooldownLoginDateTime => widget.loginDateTime; 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 _snack(String message) => context.showSnack(message);
/// 把当前输入框里的收藏品加入待提交列表。 /// 加入待提交收藏品;重复时提示并保留输入。
void _addItemToList() { bool _addItem(PendingItem item) {
final id = _itemId(); if (_pendingItems.any((e) => e.kind == item.kind && e.id == item.id)) {
if (id <= 0) {
_snack(AppStrings.collectiblesNeedItemId);
return;
}
if (_pendingItems.any((e) => e.kind == _itemKind && e.id == id)) {
_snack(AppStrings.listDuplicate); _snack(AppStrings.listDuplicate);
return; return false;
} }
setState(() { setState(() => _pendingItems.add(item));
_pendingItems.add(_PendingItem(_itemKind, id)); return true;
_itemIdController.clear();
});
} }
/// 把当前输入框里的歌曲+勾选的难度加入待解锁列表。 /// 加入待解锁歌曲;重复时提示并保留输入。
void _addMusicToList() { bool _addMusic(PendingMusic music) {
final id = _unlockMusicId(); if (_pendingMusics.any((e) => e.musicId == music.musicId)) {
if (id <= 0) {
_snack(AppStrings.unlockNeedMusicId);
return;
}
if (!_unlockMusic && !_unlockMaster && !_unlockRemaster) {
_snack(AppStrings.unlockNeedOption);
return;
}
if (_pendingMusics.any((e) => e.musicId == id)) {
_snack(AppStrings.listDuplicate); _snack(AppStrings.listDuplicate);
return; return false;
} }
setState(() { setState(() => _pendingMusics.add(music));
_pendingMusics.add( return true;
_PendingMusic(id, _unlockMusic, _unlockMaster, _unlockRemaster),
);
_unlockMusicIdController.clear();
_unlockMusic = _unlockMaster = _unlockRemaster = false;
});
} }
/// eaquira UnlockMusic.py 使用 settings 中的默认 musicData。 /// eaquira UnlockMusic.py 使用 settings 中的默认 musicData。
@@ -244,27 +151,29 @@ class _MusicRiskFeatureViewState extends State<MusicRiskFeatureView>
} }
Future<void> _fetchData() async { Future<void> _fetchData() async {
if (!TitleServerConfigHolder().isConfigured) return; final service = SessionModel.instance.createTitleService(
cookies: widget.cookies,
);
if (service == null) return;
setState(() { setState(() {
_fetching = true; riskFetching = true;
_fetchError = null; _fetchError = null;
_userAllData = null; _userAllData = null;
}); });
try { try {
final service = TitleApiService.fromHolder(cookies: widget.cookies)!;
final data = await service.fetchUserAllData(widget.userId); final data = await service.fetchUserAllData(widget.userId);
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_userAllData = data; _userAllData = data;
_fetching = false; riskFetching = false;
}); });
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_fetchError = e.toString(); _fetchError = describeApiError(e);
_fetching = false; riskFetching = false;
}); });
} }
} }
@@ -275,37 +184,36 @@ class _MusicRiskFeatureViewState extends State<MusicRiskFeatureView>
_snack(validationError); _snack(validationError);
return; return;
} }
if (!TitleServerConfigHolder().isConfigured) { final guard = riskPreconditionError(
_snack(AppStrings.ticketNotConfigured); loginDateTime: widget.loginDateTime,
return; loginId: widget.loginId,
} cooldownRemaining: cooldownRemaining,
final loginDateTime = widget.loginDateTime; notLoggedInMessage: _notLoggedInMessage,
final loginId = widget.loginId; cooldownNotice: _cooldownNotice,
if (loginDateTime == null || loginId == null) { );
_snack(_notLoggedInMessage); if (guard != null) {
return; _snack(guard);
}
if (cooldownRemaining > 0) {
_snack(_cooldownNotice(cooldownRemaining));
return; 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(() { setRiskRunning(true);
_running = true; clearRiskError();
_error = null;
});
try { try {
Map<String, Map<String, dynamic>> data = _userAllData ?? const {}; Map<String, Map<String, dynamic>> data = _userAllData ?? const {};
if (data.isEmpty) { if (data.isEmpty) {
_updateStep(RiskStep.fetchData); updateRiskStep(RiskStep.fetchData);
data = await service.fetchUserAllData(widget.userId); data = await service.fetchUserAllData(widget.userId);
if (mounted) setState(() => _userAllData = data); if (mounted) setState(() => _userAllData = data);
} }
_updateStep(RiskStep.upload); updateRiskStep(RiskStep.upload);
final musicData = _buildMusicData(); final musicData = _buildMusicData();
final builder = UserAllPayloadBuilder(service.config); final builder = UserAllPayloadBuilder(service.config);
final packet = builder.build( final packet = builder.build(
@@ -351,28 +259,19 @@ class _MusicRiskFeatureViewState extends State<MusicRiskFeatureView>
await service.upsertUserAll(packet, widget.userId); await service.upsertUserAll(packet, widget.userId);
_updateStep( updateRiskStep(
RiskStep.complete, RiskStep.complete,
_isUnlock _isUnlock
? await _verifyUnlock(service, items) ? await _verifyUnlock(service, items)
: AppStrings.collectiblesSuccess, : AppStrings.collectiblesSuccess,
); );
if (_autoLogout && widget.onExitToTitle != null) {
// 让成功状态先显示一下,再交给 HomePage 结算、退登并 pop 回主标题。 // 让成功状态先显示一下,再交给 HomePage 结算、退登并 pop 回主标题。
await Future.delayed(const Duration(seconds: 2)); await autoLogoutAndExit(widget.onExitToTitle);
if (!mounted) return;
_updateStep(RiskStep.logout, AppStrings.exitingToTitle);
await widget.onExitToTitle!();
}
} on TitleApiException catch (e) {
_updateStep(RiskStep.failed, e.message);
setState(() => _error = e.message);
} catch (e) { } catch (e) {
_updateStep(RiskStep.failed, e.toString()); reportRiskError(e);
setState(() => _error = e.toString());
} finally { } finally {
if (mounted) setState(() => _running = false); setRiskRunning(false);
} }
} }
@@ -394,7 +293,7 @@ class _MusicRiskFeatureViewState extends State<MusicRiskFeatureView>
items.map((e) => e['itemKind'] as int), items.map((e) => e['itemKind'] as int),
); );
} catch (e) { } catch (e) {
return AppStrings.unlockVerifyFailed('$e'); return AppStrings.unlockVerifyFailed(describeApiError(e));
} }
final missing = requested.where((k) => !saved.contains(k)).toList(); final missing = requested.where((k) => !saved.contains(k)).toList();
@@ -414,65 +313,57 @@ class _MusicRiskFeatureViewState extends State<MusicRiskFeatureView>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context);
if (!TitleServerConfigHolder().isConfigured) { if (!TitleServerConfigHolder().isConfigured) {
return Center( return const RiskConfigMissingView(
child: Padding( message: AppStrings.ticketNotConfigured,
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.settings_ethernet,
size: 48, color: theme.colorScheme.primary),
const SizedBox(height: 16),
Text(AppStrings.ticketNotConfigured,
style: theme.textTheme.titleMedium),
],
),
),
); );
} }
return SingleChildScrollView( return RiskPageBody(
padding: const EdgeInsets.all(16),
child: responsiveBody(
context,
child: Column(
children: [ children: [
if (!isLoggedIn) RiskStatusNotices(
AppNotice( isLoggedIn: isLoggedIn,
_notLoggedInMessage, cooldownRemaining: cooldownRemaining,
error: true, notLoggedInText: _notLoggedInMessage,
icon: Icons.warning_amber_rounded, cooldownText: _cooldownNotice,
), ),
if (isLoggedIn && cooldownRemaining > 0) RiskFeatureCard(
AppNotice( icon: _isUnlock ? Icons.lock_open : Icons.card_giftcard,
_cooldownNotice(cooldownRemaining), title: widget.featureTitle,
icon: Icons.timer_outlined, description: widget.featureDesc,
), ),
_buildDescCard(theme),
const SizedBox(height: 12), const SizedBox(height: 12),
if (_isUnlock) if (_isUnlock)
_buildUnlockCard(theme) UnlockMusicEditorCard(
enabled: !riskRunning,
onAdd: _addMusic,
onError: _snack,
pendingList: _buildUnlockPendingList(),
showDxNotice: _pendingMusics.any((m) => m.isDeluxeRange),
)
else else
_buildCollectiblesCard(theme), CollectiblesEditorCard(
const SizedBox(height: 12), enabled: !riskRunning,
_buildFetchCard(theme), onAdd: _addItem,
const SizedBox(height: 12), onError: _snack,
AutoLogoutToggle( pendingList: _buildCollectiblesPendingList(),
value: _autoLogout,
enabled: widget.onExitToTitle != null && !_running,
onChanged: (v) => setState(() => _autoLogout = v),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
_buildRunButton(theme), _buildFetchCard(),
if (_step != RiskStep.idle) ...[ const SizedBox(height: 12),
AutoLogoutToggle(
value: riskAutoLogout,
enabled: widget.onExitToTitle != null && !riskRunning,
onChanged: setRiskAutoLogout,
),
const SizedBox(height: 12),
_buildRunButton(),
if (riskStep != RiskStep.idle) ...[
const SizedBox(height: 16), const SizedBox(height: 16),
StepProgressCard( StepProgressCard(
step: _step, step: riskStep,
message: _stepMessage, message: riskStepMessage,
error: _error, error: riskError,
steps: [ steps: [
(RiskStep.fetchData, AppStrings.unlockStepFetch), (RiskStep.fetchData, AppStrings.unlockStepFetch),
( (
@@ -481,478 +372,83 @@ class _MusicRiskFeatureViewState extends State<MusicRiskFeatureView>
? AppStrings.unlockStepUpload ? AppStrings.unlockStepUpload
: AppStrings.collectiblesStepUpload, : AppStrings.collectiblesStepUpload,
), ),
if (_autoLogout) (RiskStep.logout, AppStrings.stepLogout), if (riskAutoLogout) (RiskStep.logout, AppStrings.stepLogout),
], ],
), ),
], ],
], ],
),
),
); );
} }
Widget _buildDescCard(ThemeData theme) { Widget _buildUnlockPendingList() {
return Card( return RiskPendingList(
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, title: AppStrings.unlockPendingTitle,
emptyHint: AppStrings.unlockPendingEmpty, emptyHint: AppStrings.unlockPendingEmpty,
count: _pendingMusics.length, count: _pendingMusics.length,
rows: [ rows: [
for (var i = 0; i < _pendingMusics.length; i++) for (var i = 0; i < _pendingMusics.length; i++)
_buildPendingRow( RiskPendingRow(
theme,
leading: '#${_pendingMusics[i].musicId}', leading: '#${_pendingMusics[i].musicId}',
trailing: _pendingMusics[i].optionLabel, trailing: _pendingMusics[i].optionLabel,
enabled: !riskRunning,
onRemove: () => setState(() => _pendingMusics.removeAt(i)), 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}) { Widget _buildCollectiblesPendingList() {
return SizedBox( return RiskPendingList(
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,
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,
],
);
}
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',
),
),
),
IconButton(
icon: const Icon(Icons.close, size: 18),
tooltip: AppStrings.listRemoveTooltip,
onPressed: _running ? null : onRemove,
color: theme.colorScheme.onSurfaceVariant,
visualDensity: VisualDensity.compact,
),
],
);
}
Widget _buildUnlockCheckbox(
ThemeData theme, {
required bool value,
required String label,
required bool enabled,
required ValueChanged<bool> onChanged,
}) {
return InkWell(
borderRadius: BorderRadius.circular(10),
onTap: enabled ? () => onChanged(!value) : null,
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
child: Row(
children: [
Checkbox(
value: value,
onChanged: enabled ? (v) => onChanged(v ?? false) : null,
),
Expanded(
child: Text(
label,
style: theme.textTheme.bodyMedium?.copyWith(
color: enabled
? null
: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
),
),
),
],
),
),
);
}
Widget _buildCollectiblesCard(ThemeData theme) {
final enabled = !_running;
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: theme.colorScheme.outline.withValues(alpha: 0.3)),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildDropdown(
value: _itemKind,
label: AppStrings.collectiblesItemKindLabel,
items: [
for (final kind in AppStrings.collectiblesItemKinds)
DropdownMenuItem(
value: kind,
child: Text(
'${AppStrings.collectiblesItemKindName(kind)} ($kind)',
),
),
],
onChanged: (v) => setState(() => _itemKind = v),
),
const SizedBox(height: 12),
TextField(
controller: _itemIdController,
enabled: enabled,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: AppStrings.collectiblesItemIdLabel,
hintText: AppStrings.collectiblesItemIdHint,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
),
contentPadding: const EdgeInsets.all(14),
),
),
const SizedBox(height: 8),
_buildAddButton(theme, onPressed: enabled ? _addItemToList : null),
_buildPendingList(
theme,
title: AppStrings.collectiblesPendingTitle, title: AppStrings.collectiblesPendingTitle,
emptyHint: AppStrings.collectiblesPendingEmpty, emptyHint: AppStrings.collectiblesPendingEmpty,
count: _pendingItems.length, count: _pendingItems.length,
rows: [ rows: [
for (var i = 0; i < _pendingItems.length; i++) for (var i = 0; i < _pendingItems.length; i++)
_buildPendingRow( RiskPendingRow(
theme,
leading: leading:
'${AppStrings.collectiblesItemKindName(_pendingItems[i].kind)} (${_pendingItems[i].kind})', '${AppStrings.collectiblesItemKindName(_pendingItems[i].kind)} (${_pendingItems[i].kind})',
trailing: '#${_pendingItems[i].id}', trailing: '#${_pendingItems[i].id}',
enabled: !riskRunning,
onRemove: () => setState(() => _pendingItems.removeAt(i)), onRemove: () => setState(() => _pendingItems.removeAt(i)),
), ),
], ],
),
],
),
),
); );
} }
Widget _buildFetchCard(ThemeData theme) { Widget _buildFetchCard() {
final hasData = _userAllData != null; final hasData = _userAllData != null;
return Card( return RiskFetchCard(
elevation: 0, hasData: hasData,
shape: RoundedRectangleBorder( statusText:
borderRadius: BorderRadius.circular(16), hasData ? AppStrings.unlockFetched : AppStrings.unlockNoData,
side: BorderSide( loadingLabel: AppStrings.unlockFetching,
color: hasData actionLabel:
? Colors.green.withValues(alpha: 0.5) hasData ? AppStrings.unlockRefetch : AppStrings.unlockFetchData,
: theme.colorScheme.outline.withValues(alpha: 0.3), error: _fetchError,
), loading: riskFetching,
), enabled: !riskRunning,
child: Padding( onFetch: _fetchData,
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
hasData ? Icons.check_circle : Icons.download,
size: 18,
color: hasData ? Colors.green : theme.colorScheme.primary,
),
const SizedBox(width: 8),
Expanded(
child: Text(
hasData
? AppStrings.unlockFetched
: AppStrings.unlockNoData,
style: theme.textTheme.bodySmall?.copyWith(
color: hasData
? Colors.green
: theme.colorScheme.onSurfaceVariant,
),
),
),
SizedBox(
height: 32,
child: OutlinedButton.icon(
onPressed: _fetching || _running ? null : _fetchData,
icon: _fetching
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.refresh, size: 16),
label: Text(
_fetching
? AppStrings.unlockFetching
: hasData
? AppStrings.unlockRefetch
: AppStrings.unlockFetchData,
style: theme.textTheme.labelSmall,
),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
),
],
),
if (_fetchError != null) ...[
const SizedBox(height: 12),
Container(
width: double.infinity,
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: theme.colorScheme.errorContainer,
borderRadius: BorderRadius.circular(8),
),
child: Text(
_fetchError!,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onErrorContainer,
fontFamily: 'monospace',
),
),
),
],
],
),
),
); );
} }
Widget _buildRunButton(ThemeData theme) { Widget _buildRunButton() {
final onCooldown = cooldownRemaining > 0; final onCooldown = cooldownRemaining > 0;
final canRun = widget.loginDateTime != null && final canRun = widget.loginDateTime != null &&
widget.loginId != null && widget.loginId != null &&
!_running &&
!onCooldown; !onCooldown;
return SizedBox( return RiskRunButton(
width: double.infinity, running: riskRunning,
child: FilledButton.icon( enabled: canRun,
onPressed: canRun ? _run : null, icon: onCooldown
icon: _running
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
)
: Icon(
onCooldown
? Icons.timer_outlined ? Icons.timer_outlined
: (_isUnlock ? Icons.lock_open : Icons.card_giftcard), : (_isUnlock ? Icons.lock_open : Icons.card_giftcard),
size: 20, label: _isUnlock ? AppStrings.unlockMusicRun : AppStrings.collectiblesRun,
), runningLabel: AppStrings.unlockRunning,
label: Text( disabledLabel:
_running onCooldown ? AppStrings.ticketCooldownCountdown(cooldownRemaining) : null,
? AppStrings.unlockRunning onPressed: _run,
: 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);
},
); );
} }
} }
+76 -184
View File
@@ -1,14 +1,16 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../config/responsive.dart';
import '../config/strings.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/title_api_service.dart';
import '../services/user_all_payload_builder.dart'; import '../services/user_all_payload_builder.dart';
import '../widgets/app_card.dart'; import '../widgets/app_card.dart';
import '../widgets/app_notice.dart'; import '../widgets/app_notice.dart';
import '../widgets/cooldown_mixin.dart'; import '../widgets/cooldown_mixin.dart';
import '../widgets/risk/risk_run_mixin.dart';
import '../widgets/risk/risk_widgets.dart';
import '../widgets/step_progress.dart'; import '../widgets/step_progress.dart';
/// 数值修改:Rating 与舞里程。两者的流程一模一样(拉数据 → 改一个数 → /// 数值修改:Rating 与舞里程。两者的流程一模一样(拉数据 → 改一个数 →
@@ -98,18 +100,12 @@ class NumberPatchView extends StatefulWidget {
} }
class _NumberPatchViewState extends State<NumberPatchView> class _NumberPatchViewState extends State<NumberPatchView>
with CooldownMixin<NumberPatchView> { with CooldownMixin<NumberPatchView>, RiskRunMixin<NumberPatchView> {
final _valueController = TextEditingController(); final _valueController = TextEditingController();
Map<String, Map<String, dynamic>> _userAllData = const {}; Map<String, Map<String, dynamic>> _userAllData = const {};
bool _maiMileAdd = true; 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; bool get _isRating => widget.mode == NumberPatchMode.rating;
@override @override
@@ -123,18 +119,11 @@ class _NumberPatchViewState extends State<NumberPatchView>
void _snack(String message) => context.showSnack(message); 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 里读当前值,供「累加」和界面上的当前值展示。 /// 从已拉取的 GetUserDataApi 里读当前值,供「累加」和界面上的当前值展示。
int _currentOf(Map<String, Map<String, dynamic>> data, String field) { int _currentOf(Map<String, Map<String, dynamic>> data, String field) {
final userData = data['GetUserDataApi']?['userData']; final userData = data['GetUserDataApi']?['userData'];
if (userData is! Map) return 0; if (userData is! Map) return 0;
return _toInt(userData[field]); return jsonInt(userData[field]);
} }
int get _currentRating => _currentOf(_userAllData, 'playerRating'); int get _currentRating => _currentOf(_userAllData, 'playerRating');
@@ -172,37 +161,36 @@ class _NumberPatchViewState extends State<NumberPatchView>
} }
Future<void> _fetchData() async { Future<void> _fetchData() async {
if (!TitleServerConfigHolder().isConfigured) return; final service = SessionModel.instance.createTitleService(
setState(() => _loading = true); cookies: widget.cookies,
);
if (service == null) return;
setRiskFetching(true);
try { try {
final service = TitleApiService.fromHolder(cookies: widget.cookies)!;
final data = await service.fetchUserAllData(widget.userId); final data = await service.fetchUserAllData(widget.userId);
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_userAllData = data; _userAllData = data;
_loading = false; riskFetching = false;
}); });
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
setState(() => _loading = false); setState(() => riskFetching = false);
_snack('${AppStrings.loadFailed}: $e'); _snack('${AppStrings.loadFailed}: ${describeApiError(e)}');
} }
} }
Future<void> _run() async { Future<void> _run() async {
if (!TitleServerConfigHolder().isConfigured) { final guard = riskPreconditionError(
_snack(AppStrings.ticketNotConfigured); loginDateTime: widget.loginDateTime,
return; loginId: widget.loginId,
} cooldownRemaining: cooldownRemaining,
final loginDateTime = widget.loginDateTime; notLoggedInMessage: AppStrings.numberPatchNotLoggedIn,
final loginId = widget.loginId; cooldownNotice: AppStrings.numberPatchCooldownNotice,
if (loginDateTime == null || loginId == null) { );
_snack(AppStrings.numberPatchNotLoggedIn); if (guard != null) {
return; _snack(guard);
}
if (cooldownRemaining > 0) {
_snack(AppStrings.numberPatchCooldownNotice(cooldownRemaining));
return; return;
} }
final inputError = _checkInput(); final inputError = _checkInput();
@@ -211,21 +199,26 @@ class _NumberPatchViewState extends State<NumberPatchView>
return; return;
} }
final input = int.parse(_valueController.text.trim()); final input = int.parse(_valueController.text.trim());
final loginDateTime = widget.loginDateTime!;
final loginId = widget.loginId!;
setState(() => _running = true); setRiskRunning(true);
clearRiskError();
try { try {
final service = TitleApiService.fromHolder(cookies: widget.cookies)!; final service = SessionModel.instance.createTitleService(
cookies: widget.cookies,
)!;
final builder = UserAllPayloadBuilder(service.config); final builder = UserAllPayloadBuilder(service.config);
var data = _userAllData; var data = _userAllData;
if (data.isEmpty) { if (data.isEmpty) {
_updateStep(RiskStep.fetchData); updateRiskStep(RiskStep.fetchData);
data = await service.fetchUserAllData(widget.userId); data = await service.fetchUserAllData(widget.userId);
if (mounted) setState(() => _userAllData = data); if (mounted) setState(() => _userAllData = data);
} }
_updateStep(RiskStep.upload); updateRiskStep(RiskStep.upload);
final packet = builder.build( final packet = builder.build(
userId: widget.userId, userId: widget.userId,
loginId: loginId, loginId: loginId,
@@ -237,8 +230,7 @@ class _NumberPatchViewState extends State<NumberPatchView>
final String resultMessage; final String resultMessage;
if (_isRating) { if (_isRating) {
builder.applyRatingPatch(packet, rating: input); builder.applyRatingPatch(packet, rating: input);
resultMessage = resultMessage = '${AppStrings.ratingSuccess} → ${_clamp(input)}';
'${AppStrings.ratingSuccess} → ${_clamp(input)}';
} else { } else {
final current = _currentOf(data, 'point'); final current = _currentOf(data, 'point');
final total = _currentOf(data, 'totalPoint'); final total = _currentOf(data, 'totalPoint');
@@ -253,103 +245,70 @@ class _NumberPatchViewState extends State<NumberPatchView>
} }
await service.upsertUserAll(packet, widget.userId); await service.upsertUserAll(packet, widget.userId);
_updateStep(RiskStep.complete, resultMessage); updateRiskStep(RiskStep.complete, resultMessage);
await autoLogoutAndExit(widget.onExitToTitle);
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);
} catch (e) { } catch (e) {
_updateStep(RiskStep.failed, '$e'); reportRiskError(e);
} finally { } finally {
if (mounted) setState(() => _running = false); setRiskRunning(false);
} }
} }
void _updateStep(RiskStep step, [String? message]) {
if (!mounted) return;
setState(() {
_step = step;
_stepMessage = message ?? '';
});
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
return SingleChildScrollView( return RiskPageBody(
padding: const EdgeInsets.all(16),
child: responsiveBody(
context,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
if (!isLoggedIn) RiskStatusNotices(
const AppNotice(AppStrings.numberPatchNotLoggedIn, error: true), isLoggedIn: isLoggedIn,
if (isLoggedIn && cooldownRemaining > 0) cooldownRemaining: cooldownRemaining,
AppNotice( notLoggedInText: AppStrings.numberPatchNotLoggedIn,
AppStrings.numberPatchCooldownNotice(cooldownRemaining), cooldownText: AppStrings.numberPatchCooldownNotice,
),
RiskFeatureCard(
icon: _isRating ? Icons.speed : Icons.commute,
title: widget.featureTitle,
description: widget.featureDesc,
), ),
_descCard(theme),
const SizedBox(height: 12), const SizedBox(height: 12),
_inputCard(theme), _inputCard(theme),
const SizedBox(height: 12), const SizedBox(height: 12),
_fetchCard(theme), _fetchCard(),
const SizedBox(height: 12), const SizedBox(height: 12),
AutoLogoutToggle( AutoLogoutToggle(
value: _autoLogout, value: riskAutoLogout,
enabled: widget.onExitToTitle != null && !_running, enabled: widget.onExitToTitle != null && !riskRunning,
onChanged: (v) => setState(() => _autoLogout = v), onChanged: setRiskAutoLogout,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
_runButton(theme), RiskRunButton(
if (_step != RiskStep.idle) ...[ running: riskRunning,
enabled: isLoggedIn,
icon: Icons.send,
label: AppStrings.numberPatchRun,
runningLabel: AppStrings.unlockRunning,
onPressed: _run,
),
if (riskStep != RiskStep.idle) ...[
const SizedBox(height: 16), const SizedBox(height: 16),
StepProgressCard( StepProgressCard(
step: _step, step: riskStep,
message: _stepMessage, message: riskStepMessage,
error: riskError,
steps: [ steps: [
(RiskStep.fetchData, AppStrings.unlockStepFetch), (RiskStep.fetchData, AppStrings.unlockStepFetch),
(RiskStep.upload, AppStrings.numberPatchStepUpload), (RiskStep.upload, AppStrings.numberPatchStepUpload),
if (_autoLogout) (RiskStep.logout, AppStrings.stepLogout), if (riskAutoLogout) (RiskStep.logout, AppStrings.stepLogout),
], ],
), ),
], ],
], ],
),
),
);
}
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,
),
),
],
),
); );
} }
Widget _inputCard(ThemeData theme) { Widget _inputCard(ThemeData theme) {
final enabled = !_running; final enabled = !riskRunning;
final hasInput = int.tryParse(_valueController.text.trim()) != null; final hasInput = int.tryParse(_valueController.text.trim()) != null;
return AppCard( return AppCard(
@@ -430,89 +389,22 @@ class _NumberPatchViewState extends State<NumberPatchView>
return AppStrings.numberPatchPreview(label, current, _previewTarget); return AppStrings.numberPatchPreview(label, current, _previewTarget);
} }
Widget _fetchCard(ThemeData theme) { Widget _fetchCard() {
final hasData = _userAllData.isNotEmpty; final hasData = _userAllData.isNotEmpty;
final current = _isRating ? _currentRating : _currentPoint; final current = _isRating ? _currentRating : _currentPoint;
final label = _isRating final label = _isRating
? AppStrings.ratingCurrentValue ? AppStrings.ratingCurrentValue
: AppStrings.maiMileCurrentBalance; : AppStrings.maiMileCurrentBalance;
return AppCard( return RiskFetchCard(
child: Row( hasData: hasData,
children: [ statusText: hasData ? '$label: $current' : AppStrings.unlockNoData,
Icon( loadingLabel: AppStrings.unlockFetching,
hasData ? Icons.check_circle : Icons.download, actionLabel:
size: 18, hasData ? AppStrings.unlockRefetch : AppStrings.unlockFetchData,
color: hasData ? Colors.green : theme.colorScheme.primary, loading: riskFetching,
), enabled: !riskRunning,
const SizedBox(width: 8), onFetch: _fetchData,
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),
),
),
),
); );
} }
} }
+19 -14
View File
@@ -27,8 +27,6 @@ class _SettingsPageState extends State<SettingsPage> {
final _regionNameController = TextEditingController(); final _regionNameController = TextEditingController();
final _placeNameController = TextEditingController(); final _placeNameController = TextEditingController();
final _keychipIdController = TextEditingController(); final _keychipIdController = TextEditingController();
final _aimeUrlController = TextEditingController();
final _aimeSaltController = TextEditingController();
@override @override
void initState() { void initState() {
@@ -50,8 +48,6 @@ class _SettingsPageState extends State<SettingsPage> {
_regionNameController.text = config.regionName; _regionNameController.text = config.regionName;
_placeNameController.text = config.placeName; _placeNameController.text = config.placeName;
_keychipIdController.text = config.keychipId; _keychipIdController.text = config.keychipId;
_aimeUrlController.text = config.aimeUrl;
_aimeSaltController.text = config.aimeSalt;
} }
} }
@@ -68,8 +64,6 @@ class _SettingsPageState extends State<SettingsPage> {
_regionNameController.dispose(); _regionNameController.dispose();
_placeNameController.dispose(); _placeNameController.dispose();
_keychipIdController.dispose(); _keychipIdController.dispose();
_aimeUrlController.dispose();
_aimeSaltController.dispose();
super.dispose(); super.dispose();
} }
@@ -88,11 +82,19 @@ class _SettingsPageState extends State<SettingsPage> {
regionName: _regionNameController.text.trim(), regionName: _regionNameController.text.trim(),
placeName: _placeNameController.text.trim(), placeName: _placeNameController.text.trim(),
keychipId: _keychipIdController.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 @override
@@ -139,11 +141,11 @@ class _SettingsPageState extends State<SettingsPage> {
]), ]),
const SizedBox(height: 24), const SizedBox(height: 24),
_buildSection(theme, AppStrings.authServerSettings, [ _buildSection(theme, AppStrings.authServerSettings, [
_buildField(controller: _keychipIdController, label: AppStrings.labelKeychipId), _buildField(
const SizedBox(height: 14), controller: _keychipIdController,
_buildField(controller: _aimeUrlController, label: AppStrings.labelAimeUrl), label: AppStrings.labelKeychipId,
const SizedBox(height: 14), help: AppStrings.helpKeychipId,
_buildField(controller: _aimeSaltController, label: AppStrings.labelAimeSalt), ),
]), ]),
const SizedBox(height: 32), const SizedBox(height: 32),
FilledButton.icon( FilledButton.icon(
@@ -216,11 +218,14 @@ class _SettingsPageState extends State<SettingsPage> {
Widget _buildField({ Widget _buildField({
required TextEditingController controller, required TextEditingController controller,
required String label, required String label,
String? help,
}) { }) {
return TextFormField( return TextFormField(
controller: controller, controller: controller,
decoration: InputDecoration( decoration: InputDecoration(
labelText: label, labelText: label,
helperText: help,
helperStyle: const TextStyle(fontSize: 12),
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
), ),
+66 -115
View File
@@ -1,12 +1,14 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../config/responsive.dart';
import '../config/strings.dart'; import '../config/strings.dart';
import '../config/title_server_config.dart'; import '../config/title_server_config.dart';
import '../models/session_model.dart';
import '../services/title_api_service.dart'; import '../services/title_api_service.dart';
import '../widgets/app_card.dart'; import '../widgets/app_card.dart';
import '../widgets/app_notice.dart'; import '../widgets/app_notice.dart';
import '../widgets/cooldown_mixin.dart'; import '../widgets/cooldown_mixin.dart';
import '../widgets/risk/risk_run_mixin.dart';
import '../widgets/risk/risk_widgets.dart';
import '../widgets/step_progress.dart'; import '../widgets/step_progress.dart';
class TicketPage extends StatefulWidget { class TicketPage extends StatefulWidget {
@@ -35,19 +37,13 @@ class TicketPage extends StatefulWidget {
} }
class _TicketPageState extends State<TicketPage> class _TicketPageState extends State<TicketPage>
with CooldownMixin<TicketPage> { with CooldownMixin<TicketPage>, RiskRunMixin<TicketPage> {
static const _ticketNameMap = { static const _ticketNameMap = {
2: '2倍票', 2: '2倍票',
4: '4倍票', 4: '4倍票',
5: '5倍票' 5: '5倍票'
}; };
RiskStep _step = RiskStep.idle;
String _stepMessage = '';
String? _error;
bool _running = false;
bool _autoLogout = true;
List<Map<String, dynamic>>? _tickets; List<Map<String, dynamic>>? _tickets;
bool _ticketsLoading = false; bool _ticketsLoading = false;
String? _ticketsError; String? _ticketsError;
@@ -70,16 +66,11 @@ class _TicketPageState extends State<TicketPage>
return null; return null;
} }
void _updateStep(RiskStep step, [String? message]) {
if (!mounted) return;
setState(() {
_step = step;
_stepMessage = message ?? '';
});
}
Future<void> _loadTickets() async { Future<void> _loadTickets() async {
if (!TitleServerConfigHolder().isConfigured) return; final service = SessionModel.instance.createTitleService(
cookies: widget.cookies,
);
if (service == null) return;
setState(() { setState(() {
_ticketsLoading = true; _ticketsLoading = true;
@@ -89,7 +80,6 @@ class _TicketPageState extends State<TicketPage>
}); });
try { try {
final service = TitleApiService.fromHolder(cookies: widget.cookies)!;
final data = await service.getUserCharge(widget.userId); final data = await service.getUserCharge(widget.userId);
if (!mounted) return; if (!mounted) return;
@@ -101,73 +91,67 @@ class _TicketPageState extends State<TicketPage>
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_ticketsError = e.toString(); _ticketsError = describeApiError(e);
_ticketsLoading = false; _ticketsLoading = false;
}); });
} }
} }
Future<void> _runTicket() async { Future<void> _runTicket() async {
if (_selectedTicketId == null) { final ticketId = _selectedTicketId;
if (ticketId == null) {
_snack(AppStrings.ticketNotSelected); _snack(AppStrings.ticketNotSelected);
return; return;
} }
if (!TitleServerConfigHolder().isConfigured) { final guard = riskPreconditionError(
_snack(AppStrings.ticketNotConfigured); loginDateTime: widget.loginDateTime,
return; loginId: null,
} cooldownRemaining: cooldownRemaining,
final loginDateTime = widget.loginDateTime; notLoggedInMessage: AppStrings.ticketNotLoggedIn,
if (loginDateTime == null) { cooldownNotice: AppStrings.ticketCooldownNotice,
_snack(AppStrings.ticketNotLoggedIn); requireLoginId: false,
return; );
} if (guard != null) {
if (cooldownRemaining > 0) { _snack(guard);
_snack(AppStrings.ticketCooldownNotice(cooldownRemaining));
return; return;
} }
final selectedTicket = _findTicket(_selectedTicketId!); final selectedTicket = _findTicket(ticketId);
if (selectedTicket != null && if (selectedTicket != null &&
(selectedTicket['stock'] as num?)?.toInt() != 0) { (selectedTicket['stock'] as num?)?.toInt() != 0) {
_snack(AppStrings.ticketStockNotEmpty); _snack(AppStrings.ticketStockNotEmpty);
return; return;
} }
final service = TitleApiService.fromHolder(cookies: widget.cookies)!; final service = SessionModel.instance.createTitleService(
final ticketId = _selectedTicketId!; cookies: widget.cookies,
)!;
setState(() { setRiskRunning(true);
_running = true; clearRiskError();
_error = null;
});
try { try {
_updateStep(RiskStep.upload); updateRiskStep(RiskStep.upload);
await service.upsertUserChargeLog( await service.upsertUserChargeLog(
userId: widget.userId, userId: widget.userId,
ticketId: ticketId, ticketId: ticketId,
loginDateTime: loginDateTime, loginDateTime: widget.loginDateTime!,
playerRating: widget.playerRating, playerRating: widget.playerRating,
); );
if (_autoLogout && widget.onLogoutRequested != null) { if (riskAutoLogout && widget.onLogoutRequested != null) {
_updateStep(RiskStep.logout); updateRiskStep(RiskStep.logout);
await Future.delayed(const Duration(seconds: 5)); await Future<void>.delayed(const Duration(seconds: 5));
await widget.onLogoutRequested!(); await widget.onLogoutRequested!();
} }
_updateStep( updateRiskStep(
RiskStep.complete, RiskStep.complete,
'${AppStrings.ticketUsed}: ${_ticketName(ticketId)}', '${AppStrings.ticketUsed}: ${_ticketName(ticketId)}',
); );
} on TitleApiException catch (e) {
_updateStep(RiskStep.failed, e.message);
setState(() => _error = e.message);
} catch (e) { } catch (e) {
_updateStep(RiskStep.failed, e.toString()); reportRiskError(e);
setState(() => _error = e.toString());
} finally { } finally {
if (mounted) setState(() => _running = false); setRiskRunning(false);
} }
} }
@@ -176,63 +160,46 @@ class _TicketPageState extends State<TicketPage>
final theme = Theme.of(context); final theme = Theme.of(context);
if (!TitleServerConfigHolder().isConfigured) { if (!TitleServerConfigHolder().isConfigured) {
return Center( return const RiskConfigMissingView(
child: Padding( message: AppStrings.ticketNotConfigured,
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.settings_ethernet, size: 48, color: theme.colorScheme.primary),
const SizedBox(height: 16),
Text(AppStrings.ticketNotConfigured, style: theme.textTheme.titleMedium),
],
),
),
); );
} }
return SingleChildScrollView( return _buildBody(theme);
padding: const EdgeInsets.all(16), }
child: responsiveBody(
context, Widget _buildBody(ThemeData theme) {
child: Column( return RiskPageBody(
children: [ children: [
if (!isLoggedIn) RiskStatusNotices(
const AppNotice( isLoggedIn: isLoggedIn,
AppStrings.ticketNotLoggedIn, cooldownRemaining: cooldownRemaining,
error: true, notLoggedInText: AppStrings.ticketNotLoggedIn,
icon: Icons.warning_amber_rounded, cooldownText: AppStrings.ticketCooldownNotice,
),
if (isLoggedIn && cooldownRemaining > 0)
AppNotice(
AppStrings.ticketCooldownNotice(cooldownRemaining),
icon: Icons.timer_outlined,
), ),
_buildTicketListCard(theme), _buildTicketListCard(theme),
const SizedBox(height: 16), const SizedBox(height: 16),
AutoLogoutToggle( AutoLogoutToggle(
value: _autoLogout, value: riskAutoLogout,
enabled: widget.onLogoutRequested != null && !_running, enabled: widget.onLogoutRequested != null && !riskRunning,
onChanged: (v) => setState(() => _autoLogout = v), onChanged: setRiskAutoLogout,
label: AppStrings.ticketAutoLogout, label: AppStrings.ticketAutoLogout,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
_buildRunButton(theme), _buildRunButton(theme),
if (_step != RiskStep.idle) ...[ if (riskStep != RiskStep.idle) ...[
const SizedBox(height: 16), const SizedBox(height: 16),
StepProgressCard( StepProgressCard(
step: _step, step: riskStep,
message: _stepMessage, message: riskStepMessage,
error: _error, error: riskError,
steps: [ steps: [
(RiskStep.upload, AppStrings.stepChargeTicket), (RiskStep.upload, AppStrings.stepChargeTicket),
if (_autoLogout) (RiskStep.logout, AppStrings.stepLogout), if (riskAutoLogout) (RiskStep.logout, AppStrings.stepLogout),
], ],
), ),
], ],
], ],
),
),
); );
} }
@@ -327,7 +294,7 @@ class _TicketPageState extends State<TicketPage>
return InkWell( return InkWell(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
onTap: _running onTap: riskRunning
? null ? null
: () { : () {
setState(() { setState(() {
@@ -415,7 +382,7 @@ class _TicketPageState extends State<TicketPage>
? _ticketName(_selectedTicketId!) ? _ticketName(_selectedTicketId!)
: null; : null;
final onCooldown = cooldownRemaining > 0; final onCooldown = cooldownRemaining > 0;
final canRun = widget.loginDateTime != null && !_running && !onCooldown; final canRun = widget.loginDateTime != null && !onCooldown;
return Column( return Column(
children: [ children: [
@@ -437,34 +404,18 @@ class _TicketPageState extends State<TicketPage>
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
], ],
SizedBox( RiskRunButton(
width: double.infinity, running: riskRunning,
child: FilledButton.icon( enabled: canRun,
onPressed: canRun ? _runTicket : null, icon: onCooldown ? Icons.timer_outlined : Icons.play_arrow,
icon: _running label: AppStrings.runTicket,
? const SizedBox( runningLabel: AppStrings.runningTicket,
width: 18, disabledLabel: onCooldown
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.ticketCooldownCountdown(cooldownRemaining)
: AppStrings.runTicket, : null,
), onPressed: _runTicket,
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
), ),
], ],
); );
} }
} }
+89 -193
View File
@@ -1,15 +1,16 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../config/responsive.dart';
import '../config/strings.dart'; import '../config/strings.dart';
import '../config/title_server_config.dart'; import '../models/session_model.dart';
import '../models/user_character.dart'; import '../models/user_character.dart';
import '../services/title_api_service.dart'; import '../services/title_api_service.dart';
import '../services/user_all_payload_builder.dart'; import '../services/user_all_payload_builder.dart';
import '../widgets/app_card.dart'; import '../widgets/app_card.dart';
import '../widgets/app_notice.dart'; import '../widgets/app_notice.dart';
import '../widgets/cooldown_mixin.dart'; import '../widgets/cooldown_mixin.dart';
import '../widgets/risk/risk_run_mixin.dart';
import '../widgets/risk/risk_widgets.dart';
import '../widgets/step_progress.dart'; import '../widgets/step_progress.dart';
/// 旅行伙伴页:发放角色 + 编组出战槽位。 /// 旅行伙伴页:发放角色 + 编组出战槽位。
@@ -44,7 +45,7 @@ class TravelPartnerPage extends StatefulWidget {
} }
class _TravelPartnerPageState extends State<TravelPartnerPage> class _TravelPartnerPageState extends State<TravelPartnerPage>
with CooldownMixin<TravelPartnerPage> { with CooldownMixin<TravelPartnerPage>, RiskRunMixin<TravelPartnerPage> {
final _grantIdController = TextEditingController(); final _grantIdController = TextEditingController();
/// 每个出战槽位一个等级输入框,留空表示不改该角色的等级。 /// 每个出战槽位一个等级输入框,留空表示不改该角色的等级。
@@ -58,12 +59,6 @@ class _TravelPartnerPageState extends State<TravelPartnerPage>
final List<int> _pendingGrant = []; final List<int> _pendingGrant = [];
List<int> _slots = normalizeCharaSlot(const []); List<int> _slots = normalizeCharaSlot(const []);
bool _loading = false;
bool _running = false;
bool _autoLogout = true;
RiskStep _step = RiskStep.idle;
String _stepMessage = '';
@override @override
int? get cooldownLoginDateTime => widget.loginDateTime; int? get cooldownLoginDateTime => widget.loginDateTime;
@@ -84,11 +79,13 @@ class _TravelPartnerPageState extends State<TravelPartnerPage>
_pendingGrant.contains(characterId)); _pendingGrant.contains(characterId));
Future<void> _fetchData() async { Future<void> _fetchData() async {
if (!TitleServerConfigHolder().isConfigured) return; final service = SessionModel.instance.createTitleService(
setState(() => _loading = true); cookies: widget.cookies,
);
if (service == null) return;
setRiskFetching(true);
try { try {
final service = TitleApiService.fromHolder(cookies: widget.cookies)!;
final results = await Future.wait([ final results = await Future.wait([
service.getUserCharacters(widget.userId), service.getUserCharacters(widget.userId),
service.fetchUserAllData(widget.userId), service.fetchUserAllData(widget.userId),
@@ -109,12 +106,12 @@ class _TravelPartnerPageState extends State<TravelPartnerPage>
_userAllData = all; _userAllData = all;
_slots = normalizeCharaSlot(slots); _slots = normalizeCharaSlot(slots);
// 槽位里引用了但列表没有的角色,保留为可选,避免下拉框丢项。 // 槽位里引用了但列表没有的角色,保留为可选,避免下拉框丢项。
_loading = false; riskFetching = false;
}); });
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
setState(() => _loading = false); setState(() => riskFetching = false);
_snack('${AppStrings.loadFailed}: $e'); _snack('${AppStrings.loadFailed}: ${describeApiError(e)}');
} }
} }
@@ -192,18 +189,15 @@ class _TravelPartnerPageState extends State<TravelPartnerPage>
} }
Future<void> _run() async { Future<void> _run() async {
if (!TitleServerConfigHolder().isConfigured) { final guard = riskPreconditionError(
_snack(AppStrings.ticketNotConfigured); loginDateTime: widget.loginDateTime,
return; loginId: widget.loginId,
} cooldownRemaining: cooldownRemaining,
final loginDateTime = widget.loginDateTime; notLoggedInMessage: AppStrings.travelPartnerNotLoggedIn,
final loginId = widget.loginId; cooldownNotice: AppStrings.travelPartnerCooldownNotice,
if (loginDateTime == null || loginId == null) { );
_snack(AppStrings.travelPartnerNotLoggedIn); if (guard != null) {
return; _snack(guard);
}
if (cooldownRemaining > 0) {
_snack(AppStrings.travelPartnerCooldownNotice(cooldownRemaining));
return; return;
} }
final validationError = _validate(); final validationError = _validate();
@@ -212,20 +206,25 @@ class _TravelPartnerPageState extends State<TravelPartnerPage>
return; return;
} }
setState(() => _running = true); final loginDateTime = widget.loginDateTime!;
final loginId = widget.loginId!;
setRiskRunning(true);
clearRiskError();
try { try {
final service = TitleApiService.fromHolder(cookies: widget.cookies)!; final service = SessionModel.instance.createTitleService(
cookies: widget.cookies,
)!;
final builder = UserAllPayloadBuilder(service.config); final builder = UserAllPayloadBuilder(service.config);
var data = _userAllData; var data = _userAllData;
if (data.isEmpty) { if (data.isEmpty) {
_updateStep(RiskStep.fetchData); updateRiskStep(RiskStep.fetchData);
data = await service.fetchUserAllData(widget.userId); data = await service.fetchUserAllData(widget.userId);
if (mounted) setState(() => _userAllData = data); if (mounted) setState(() => _userAllData = data);
} }
_updateStep(RiskStep.upload); updateRiskStep(RiskStep.upload);
final packet = builder.build( final packet = builder.build(
userId: widget.userId, userId: widget.userId,
loginId: loginId, loginId: loginId,
@@ -273,25 +272,17 @@ class _TravelPartnerPageState extends State<TravelPartnerPage>
// 两种失败在返回值上完全一样(returnCode 都是 1),只能靠回读区分: // 两种失败在返回值上完全一样(returnCode 都是 1),只能靠回读区分:
// 服务器没写入 userCharacterList,还是写入了但 ID 不在机台 Chara 表里 // 服务器没写入 userCharacterList,还是写入了但 ID 不在机台 Chara 表里
// 被客户端 CharacterSelectProces 静默跳过。 // 被客户端 CharacterSelectProces 静默跳过。
_updateStep( updateRiskStep(
RiskStep.complete, RiskStep.complete,
_pendingGrant.isEmpty _pendingGrant.isEmpty
? AppStrings.travelPartnerSuccess ? AppStrings.travelPartnerSuccess
: await _verifyGranted(service), : await _verifyGranted(service),
); );
await autoLogoutAndExit(widget.onExitToTitle);
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);
} catch (e) { } catch (e) {
_updateStep(RiskStep.failed, '$e'); reportRiskError(e);
} finally { } finally {
if (mounted) setState(() => _running = false); setRiskRunning(false);
} }
} }
@@ -301,7 +292,7 @@ class _TravelPartnerPageState extends State<TravelPartnerPage>
try { try {
owned = await service.getUserCharacters(widget.userId); owned = await service.getUserCharacters(widget.userId);
} catch (e) { } catch (e) {
return AppStrings.travelPartnerVerifyFailed('$e'); return AppStrings.travelPartnerVerifyFailed(describeApiError(e));
} }
if (mounted) setState(() => _owned = owned); if (mounted) setState(() => _owned = owned);
@@ -311,14 +302,6 @@ class _TravelPartnerPageState extends State<TravelPartnerPage>
return AppStrings.travelPartnerNotSaved(missing.join(' / ')); return AppStrings.travelPartnerNotSaved(missing.join(' / '));
} }
void _updateStep(RiskStep step, [String? message]) {
if (!mounted) return;
setState(() {
_step = step;
_stepMessage = message ?? '';
});
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
@@ -327,29 +310,32 @@ class _TravelPartnerPageState extends State<TravelPartnerPage>
appBar: AppBar( appBar: AppBar(
title: const Text(AppStrings.travelPartnerFeatureTitle), title: const Text(AppStrings.travelPartnerFeatureTitle),
), ),
body: SingleChildScrollView( body: RiskPageBody(
padding: const EdgeInsets.all(16),
child: responsiveBody(
context,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
if (!isLoggedIn) RiskStatusNotices(
const AppNotice( isLoggedIn: isLoggedIn,
AppStrings.travelPartnerNotLoggedIn, cooldownRemaining: cooldownRemaining,
error: true, notLoggedInText: AppStrings.travelPartnerNotLoggedIn,
cooldownText: AppStrings.travelPartnerCooldownNotice,
), ),
if (isLoggedIn && cooldownRemaining > 0) RiskFeatureCard(
AppNotice(
AppStrings.travelPartnerCooldownNotice(cooldownRemaining),
),
_sectionCard(
theme,
icon: Icons.card_giftcard, icon: Icons.card_giftcard,
title: AppStrings.travelPartnerFeatureDesc, title: AppStrings.travelPartnerFeatureDesc,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
_fetchCard(theme), 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), const SizedBox(height: 12),
_grantCard(theme), _grantCard(theme),
const SizedBox(height: 12), const SizedBox(height: 12),
@@ -358,113 +344,44 @@ class _TravelPartnerPageState extends State<TravelPartnerPage>
_ownedCard(theme), _ownedCard(theme),
const SizedBox(height: 12), const SizedBox(height: 12),
AutoLogoutToggle( AutoLogoutToggle(
value: _autoLogout, value: riskAutoLogout,
enabled: widget.onExitToTitle != null && !_running, enabled: widget.onExitToTitle != null && !riskRunning,
onChanged: (v) => setState(() => _autoLogout = v), onChanged: setRiskAutoLogout,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
_runButton(theme), RiskRunButton(
if (_step != RiskStep.idle) ...[ running: riskRunning,
enabled: isLoggedIn,
icon: Icons.send,
label: AppStrings.travelPartnerRun,
runningLabel: AppStrings.travelPartnerRunning,
onPressed: _run,
),
if (riskStep != RiskStep.idle) ...[
const SizedBox(height: 16), const SizedBox(height: 16),
StepProgressCard( StepProgressCard(
step: _step, step: riskStep,
message: _stepMessage, message: riskStepMessage,
error: riskError,
steps: [ steps: [
( (RiskStep.fetchData, AppStrings.travelPartnerStepFetch),
RiskStep.fetchData, (RiskStep.upload, AppStrings.travelPartnerStepUpload),
AppStrings.travelPartnerStepFetch, if (riskAutoLogout) (RiskStep.logout, AppStrings.stepLogout),
),
(
RiskStep.upload,
AppStrings.travelPartnerStepUpload,
),
if (_autoLogout) (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) { Widget _grantCard(ThemeData theme) {
final enabled = !_running; final enabled = !riskRunning;
return _sectionCard( return RiskFeatureCard(
theme,
icon: Icons.add_circle_outline, icon: Icons.add_circle_outline,
title: AppStrings.travelPartnerGrantTitle, title: AppStrings.travelPartnerGrantTitle,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const SizedBox(height: 14), const SizedBox(height: 14),
TextField( TextField(
@@ -532,6 +449,7 @@ class _TravelPartnerPageState extends State<TravelPartnerPage>
), ),
], ],
], ],
),
); );
} }
@@ -558,10 +476,11 @@ class _TravelPartnerPageState extends State<TravelPartnerPage>
}.toList() }.toList()
..sort(); ..sort();
return _sectionCard( return RiskFeatureCard(
theme,
icon: Icons.groups_outlined, icon: Icons.groups_outlined,
title: AppStrings.travelPartnerSlotTitle, title: AppStrings.travelPartnerSlotTitle,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
const SizedBox(height: 14), const SizedBox(height: 14),
for (var i = 0; i < 5; i++) for (var i = 0; i < 5; i++)
@@ -593,7 +512,7 @@ class _TravelPartnerPageState extends State<TravelPartnerPage>
for (final id in optionIds) for (final id in optionIds)
DropdownMenuItem(value: id, child: Text('#$id')), DropdownMenuItem(value: id, child: Text('#$id')),
], ],
onChanged: _running onChanged: riskRunning
? null ? null
: (v) => setState(() { : (v) => setState(() {
final next = [..._slots]; final next = [..._slots];
@@ -605,7 +524,7 @@ class _TravelPartnerPageState extends State<TravelPartnerPage>
const SizedBox(height: 6), const SizedBox(height: 6),
TextField( TextField(
controller: _slotLevelControllers[i], controller: _slotLevelControllers[i],
enabled: !_running, enabled: !riskRunning,
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
onChanged: (_) => setState(() {}), onChanged: (_) => setState(() {}),
decoration: InputDecoration( decoration: InputDecoration(
@@ -629,7 +548,7 @@ class _TravelPartnerPageState extends State<TravelPartnerPage>
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
child: OutlinedButton.icon( child: OutlinedButton.icon(
onPressed: _running ? null : _copySlot0ToOthers, onPressed: riskRunning ? null : _copySlot0ToOthers,
icon: const Icon(Icons.copy_all_outlined, size: 18), icon: const Icon(Icons.copy_all_outlined, size: 18),
label: const Text(AppStrings.travelPartnerCopySlot0), label: const Text(AppStrings.travelPartnerCopySlot0),
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
@@ -641,6 +560,7 @@ class _TravelPartnerPageState extends State<TravelPartnerPage>
), ),
), ),
], ],
),
); );
} }
@@ -664,11 +584,11 @@ class _TravelPartnerPageState extends State<TravelPartnerPage>
} }
Widget _ownedCard(ThemeData theme) { Widget _ownedCard(ThemeData theme) {
return _sectionCard( return RiskFeatureCard(
theme,
icon: Icons.workspace_premium_outlined, icon: Icons.workspace_premium_outlined,
title: title: '${AppStrings.travelPartnerOwnedTitle} (${_owned.length})',
'${AppStrings.travelPartnerOwnedTitle} (${_owned.length})', child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const SizedBox(height: 14), const SizedBox(height: 14),
if (_owned.isEmpty) if (_owned.isEmpty)
@@ -704,30 +624,6 @@ class _TravelPartnerPageState extends State<TravelPartnerPage>
], ],
), ),
], ],
);
}
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(); final value = await read();
sections[name] = value; sections[name] = value;
} catch (e) { } catch (e) {
failed.add('$name: $e'); failed.add('$name: ${describeApiError(e)}');
} }
} }
@@ -103,7 +103,7 @@ class AccountBackupService {
try { try {
items['$kind'] = await _api.getUserItems(userId, kind); items['$kind'] = await _api.getUserItems(userId, kind);
} catch (e) { } 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)');
}
+134 -53
View File
@@ -1,9 +1,9 @@
import 'dart:convert'; import 'dart:convert';
import 'package:crypto/crypto.dart'; import 'package:crypto/crypto.dart';
import 'package:http/http.dart' as http;
import '../config/title_server_config.dart'; import '../config/title_server_config.dart';
import 'aimedb_transport.dart';
import 'api_log.dart'; import 'api_log.dart';
class LoginResult { class LoginResult {
@@ -12,90 +12,171 @@ class LoginResult {
final int userId; final int userId;
final String token; final String token;
/// 服务器拒绝登录时给用户的说明(含 errorID 的含义)。
final String? message;
const LoginResult({ const LoginResult({
required this.success, required this.success,
required this.errorId, required this.errorId,
required this.userId, required this.userId,
required this.token, required this.token,
this.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 { 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; final TitleServerConfig? _config;
ApiService([this._config]); ApiService([this._config]);
TitleServerConfig? get _cfg => _config ?? TitleServerConfigHolder().config; TitleServerConfig? get _cfg => _config ?? TitleServerConfigHolder().config;
String get _chipId => _cfg?.keychipId ?? '';
String get _aimeSalt => _cfg?.aimeSalt ?? ''; /// 任意输入 -> 标准 84 字符二维码串,格式不合预期时抛 [FormatException]。
String get _aimeUrl => _cfg?.aimeUrl ?? ''; static String normalizeQr(String input) {
String get _openGameID { var qr = input.trim();
final v = _cfg?.openGameID;
return (v != null && v.isNotEmpty) ? v : 'MAID'; 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)!;
} }
String _formatTimestamp() { if (qr.length != _qrTotalLength) {
final tokyo = DateTime.now().toUtc().add(const Duration(hours: 9)); throw FormatException(
final y = (tokyo.year % 100).toString().padLeft(2, '0'); '二维码长度应为 $_qrTotalLength 字符,实际 ${qr.length} 字符',
final M = tokyo.month.toString().padLeft(2, '0'); );
final d = tokyo.day.toString().padLeft(2, '0'); }
final h = tokyo.hour.toString().padLeft(2, '0'); if (!qr.startsWith(_qrPrefix)) {
final m = tokyo.minute.toString().padLeft(2, '0'); throw FormatException('二维码应当以 "$_qrPrefix" 开头');
final s = tokyo.second.toString().padLeft(2, '0'); }
return '$y$M$d$h$m$s'; 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;
} }
String _sha256(String input) { /// 请求体 `qrCode` 字段的值:84 串去掉前 20 位后的 64 位载荷。
final bytes = utf8.encode(input); static String extractQRCode(String input) =>
return sha256.convert(bytes).toString(); normalizeQr(input).substring(_qrHeadLength);
/// 真机报的是机台本地墙上时间,所以这里跟着设备时区走,不固定偏移。
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)}';
} }
static String extractQRCode(String qrCodeToken) { Future<LoginResult> login(String qrInput) async {
if (qrCodeToken.length > 64) { final chipId = _cfg?.fullKeychipId ?? '';
return qrCodeToken.substring(qrCodeToken.length - 64); if (chipId.isEmpty) {
} throw const FormatException('设置里没有 Keychip,无法生成签名');
return qrCodeToken;
} }
Future<LoginResult> login(String qrCodeToken) async { final qr = normalizeQr(qrInput);
final timestamp = _formatTimestamp(); final timestamp = formatTimestamp(DateTime.now());
final qrCode = extractQRCode(qrCodeToken); final key = sha256
final chipId = _chipId; .convert(utf8.encode(chipId + timestamp + _signKey))
final rawKey = chipId + timestamp + _aimeSalt; .toString()
final key = _sha256(rawKey).toUpperCase(); .toUpperCase();
final body = jsonEncode({ final bodyBytes = utf8.encode(
jsonEncode({
'chipID': chipId, 'chipID': chipId,
'openGameID': _openGameID, 'openGameID': _openGameId,
'key': key, 'key': key,
'qrCode': qrCode, 'qrCode': qr.substring(_qrHeadLength),
'timestamp': timestamp, 'timestamp': timestamp,
}); 'titlekey': _titleKey,
}),
);
ApiLog.log('[login] POST $_aimeUrl'); ApiLog.log('[login] POST https://$_aimeHost$_aimePath');
ApiLog.log('[login] chipId=$chipId openGameID=$_openGameID'); ApiLog.log('[login] chipID=$chipId timestamp=$timestamp');
final response = await http // 不带 Content-Type:aimedb 见到这个头会回 errorID=57。
.post( // 传输层在 Windows 上走 WinHTTP,绕过服务器对 BoringSSL 指纹的拦截。
Uri.parse(_aimeUrl), final response = await AimedbTransport.post(
headers: { _aimeUri,
'Content-Type': 'application/json', headers: const {'Connection': 'Keep-Alive', 'User-Agent': _userAgent},
'User-Agent': 'WC_AIME_LIB', body: bodyBytes,
}, ).timeout(const Duration(seconds: 25));
body: body,
)
.timeout(const Duration(seconds: 15));
final obj = jsonDecode(response.body) as Map<String, dynamic>; ApiLog.log(
final errorId = obj['errorID'] as int? ?? -1; '[login] HTTP ${response.statusCode}, ${response.bodyBytes.length} bytes',
final userId = obj['userID'] as int? ?? -1; );
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? ?? ''; final token = obj['token'] as String? ?? '';
// Session cookies (JSESSIONID) are captured from the title server's if (errorId != 0) {
// UserLoginApi response, not from the Aime server — see empurple.
return LoginResult( return LoginResult(
success: errorId == 0, 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: true,
errorId: errorId, errorId: errorId,
userId: userId, userId: userId,
token: token, token: token,
+4 -17
View File
@@ -2,6 +2,7 @@ import 'dart:convert';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import '../core/json_value.dart';
import 'music_data_cache.dart'; import 'music_data_cache.dart';
class MusicInfo { class MusicInfo {
@@ -62,32 +63,18 @@ class MusicDataService {
if (decoded is! List) return; if (decoded is! List) return;
_byId.clear(); _byId.clear();
for (final item in decoded.whereType<Map<String, dynamic>>()) { for (final item in decoded.whereType<Map<String, dynamic>>()) {
final id = _toInt(item['id']); final id = jsonInt(item['id']);
if (id == 0) continue; if (id == 0) continue;
final ds = item['ds']; final ds = item['ds'];
_byId[id] = MusicInfo( _byId[id] = MusicInfo(
title: item['title']?.toString() ?? 'Unknown', title: jsonString(item['title'], fallback: 'Unknown'),
dsByLevel: ds is List dsByLevel: ds is List
? { ? {
for (var level = 0; level < ds.length; level++) for (var level = 0; level < ds.length; level++)
level: _toDouble(ds[level]), level: jsonDouble(ds[level]),
} }
: const {}, : 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 'package:pointycastle/export.dart';
import '../config/title_server_config.dart'; import '../config/title_server_config.dart';
import '../core/date_time_format.dart';
import '../models/user_data.dart'; import '../models/user_data.dart';
import 'api_log.dart'; import 'api_log.dart';
import '../models/user_character.dart'; import '../models/user_character.dart';
@@ -22,6 +23,12 @@ class TitleApiException implements Exception {
String toString() => 'TitleApiException: $message'; 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 { class UserLoginResult {
final String token; final String token;
final int loginId; final int loginId;
@@ -39,21 +46,37 @@ class UserLoginResult {
class TitleApiService { class TitleApiService {
static const String _obfuscateConstant = 'MaimaiChn'; static const String _obfuscateConstant = 'MaimaiChn';
static String? lastRawResponse; /// 最近一次 `captureRaw` 请求的响应原文(主页调试卡用)。
String? lastRawResponse;
final TitleServerConfig _config; final TitleServerConfig _config;
final CookiesChangedCallback? onCookiesChanged;
String? _cookies; String? _cookies;
TitleApiService(this._config, {this._cookies}); // 私有命名参数不合法(不能写 `this._cookies`),只能手写初始化。
TitleApiService(
this._config, {
String? cookies,
this.onCookiesChanged,
}) :
// ignore: prefer_initializing_formals
_cookies = cookies;
/// 从 [TitleServerConfigHolder] 构造;未配置时返回 `null`。 /// 从 [TitleServerConfigHolder] 构造;未配置时返回 `null`。
/// ///
/// 各页原本都写 `if (!holder.isConfigured) ...; TitleApiService(holder.config!, ...)`, /// 各页原本都写 `if (!holder.isConfigured) ...; TitleApiService(holder.config!, ...)`,
/// 收口成一个工厂,避免 `config!` 与 `isConfigured` 散落在十几个地方。 /// 收口成一个工厂,避免 `config!` 与 `isConfigured` 散落在十几个地方。
static TitleApiService? fromHolder({String? cookies}) { static TitleApiService? fromHolder({
String? cookies,
CookiesChangedCallback? onCookiesChanged,
}) {
final holder = TitleServerConfigHolder(); final holder = TitleServerConfigHolder();
if (!holder.isConfigured) return null; if (!holder.isConfigured) return null;
return TitleApiService(holder.config!, cookies: cookies); return TitleApiService(
holder.config!,
cookies: cookies,
onCookiesChanged: onCookiesChanged,
);
} }
String? get cookies => _cookies; String? get cookies => _cookies;
@@ -122,6 +145,7 @@ class TitleApiService {
if (match != null) { if (match != null) {
_cookies = 'JSESSIONID=${match.group(1)!.trim()}'; _cookies = 'JSESSIONID=${match.group(1)!.trim()}';
ApiLog.log('[$apiName] >>> Captured session cookie: $_cookies'); ApiLog.log('[$apiName] >>> Captured session cookie: $_cookies');
onCookiesChanged?.call(_cookies!);
return; return;
} }
} }
@@ -243,8 +267,8 @@ class TitleApiService {
required int userId, required int userId,
required String token, required String token,
}) async { }) async {
// 走通用传输层,保留 `lastRawResponse`(主页调试卡要用 GetUserPreviewApi // 走通用传输层,把 GetUserPreviewApi 的原文留在实例上(主页调试卡要用它,
// 的原文,而不是随后 UserLoginApi / GetUserDataApi 的)。 // 而不是随后 UserLoginApi / GetUserDataApi 的响应)。
final json = await _callApi( final json = await _callApi(
'GetUserPreviewApi', 'GetUserPreviewApi',
{ {
@@ -386,10 +410,8 @@ class TitleApiService {
final now = DateTime.now(); final now = DateTime.now();
final validDate = DateTime(now.year, now.month, now.day, 4, 0, 0) final validDate = DateTime(now.year, now.month, now.day, 4, 0, 0)
.add(const Duration(days: 90)); .add(const Duration(days: 90));
final purchaseDateStr = _formatDateTime(now); final purchaseDateStr = formatPlayDateTime(now);
final validDateStr = final validDateStr = formatDateTime(validDate);
'${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 packet = { final packet = {
'userId': userId, 'userId': userId,
@@ -416,11 +438,6 @@ class TitleApiService {
_checkReturnCode(json, apiName); _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 (发包) ---- // ---- UpsertUserAll (发包) ----
/// Upload a single playlog record (fake game log). /// Upload a single playlog record (fake game log).
+8 -23
View File
@@ -1,4 +1,6 @@
import '../config/title_server_config.dart'; import '../config/title_server_config.dart';
import '../core/date_time_format.dart';
import '../core/json_value.dart';
import '../models/user_character.dart'; import '../models/user_character.dart';
import 'title_api_service.dart'; import 'title_api_service.dart';
@@ -43,28 +45,12 @@ class UserAllPayloadBuilder {
const UserAllPayloadBuilder(this.config); const UserAllPayloadBuilder(this.config);
static DateTime _shanghaiNow() =>
DateTime.now().toUtc().add(const Duration(hours: 8));
static String _two(int v) => v.toString().padLeft(2, '0');
static String _formatPlayDate(DateTime dt) =>
'${dt.year}-${_two(dt.month)}-${_two(dt.day)}';
static String _formatPlayDateTime(DateTime dt) =>
'${dt.year}-${_two(dt.month)}-${_two(dt.day)} '
'${_two(dt.hour)}:${_two(dt.minute)}:${_two(dt.second)}.0';
/// 客户端 `TimeManager.GetDateString` 的格式是 /// 客户端 `TimeManager.GetDateString` 的格式是
/// `ToString("yyyy-MM-dd HH:mm:ss.f")`,也就是带一位小数的秒。 /// `ToString("yyyy-MM-dd HH:mm:ss.f")`,也就是带一位小数的秒。
/// 万花筒的 `clearDate` 之类字段要按这个形状给。 /// 万花筒的 `clearDate` 之类字段要按这个形状给。
static String nowTimestamp() => _formatPlayDateTime(_shanghaiNow()); static String nowTimestamp() => formatPlayDateTime(shanghaiNow());
static int _toInt(dynamic v) { static int _toInt(dynamic v) => jsonInt(v);
if (v is int) return v;
if (v is num) return v.toInt();
return 0;
}
static int clampValue(int v) => static int clampValue(int v) =>
v < minValue ? minValue : (v > maxValue ? maxValue : v); v < minValue ? minValue : (v > maxValue ? maxValue : v);
@@ -122,11 +108,10 @@ class UserAllPayloadBuilder {
(i) => i < charaSlot.length ? charaSlot[i] : 0, (i) => i < charaSlot.length ? charaSlot[i] : 0,
); );
final now = _shanghaiNow(); final now = shanghaiNow();
final playDate = _formatPlayDate(now); final playDate = formatDate(now);
final playDateTime = _formatPlayDateTime(now); final playDateTime = formatPlayDateTime(now);
final loginDate = final loginDate = formatPlayDateTime(shanghaiFromUnixSeconds(loginDateTime));
_formatPlayDateTime(DateTime.fromMillisecondsSinceEpoch(loginDateTime * 1000).toUtc().add(const Duration(hours: 8)));
// Lionheart: GetUserDataApi 返回的部分字段不能出现在 UpsertUserAll 的 // Lionheart: GetUserDataApi 返回的部分字段不能出现在 UpsertUserAll 的
// userData 中,否则服务器会静默丢弃整包处理。 // userData 中,否则服务器会静默丢弃整包处理。
+5 -2
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show Clipboard, ClipboardData; import 'package:flutter/services.dart' show Clipboard, ClipboardData;
import '../config/strings.dart'; import '../config/strings.dart';
import '../models/session_model.dart';
import '../services/account_backup_service.dart'; import '../services/account_backup_service.dart';
import '../services/title_api_service.dart'; import '../services/title_api_service.dart';
import 'app_card.dart'; import 'app_card.dart';
@@ -28,7 +29,9 @@ class _AccountBackupCardState extends State<AccountBackupCard> {
String? _error; String? _error;
Future<void> _run() async { Future<void> _run() async {
final service = TitleApiService.fromHolder(cookies: widget.cookies); final service = SessionModel.instance.createTitleService(
cookies: widget.cookies,
);
if (service == null) { if (service == null) {
context.showSnack(AppStrings.ticketNotConfigured); context.showSnack(AppStrings.ticketNotConfigured);
return; return;
@@ -56,7 +59,7 @@ class _AccountBackupCardState extends State<AccountBackupCard> {
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_error = '$e'; _error = describeApiError(e);
_running = false; _running = false;
}); });
} }
+11 -1
View File
@@ -11,6 +11,7 @@ class AppCard extends StatelessWidget {
final EdgeInsetsGeometry padding; final EdgeInsetsGeometry padding;
final Color? borderColor; final Color? borderColor;
final EdgeInsetsGeometry? margin; final EdgeInsetsGeometry? margin;
final VoidCallback? onTap;
const AppCard({ const AppCard({
super.key, super.key,
@@ -18,11 +19,14 @@ class AppCard extends StatelessWidget {
this.padding = const EdgeInsets.all(20), this.padding = const EdgeInsets.all(20),
this.borderColor, this.borderColor,
this.margin, this.margin,
this.onTap,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final content = Padding(padding: padding, child: child);
return Card( return Card(
elevation: 0, elevation: 0,
margin: margin, margin: margin,
@@ -33,7 +37,13 @@ class AppCard extends StatelessWidget {
theme.colorScheme.outline.withValues(alpha: 0.3), 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),
),
),
),
],
),
);
}
}
+1 -1
View File
@@ -114,7 +114,7 @@ packages:
source: hosted source: hosted
version: "1.3.3" version: "1.3.3"
ffi: ffi:
dependency: transitive dependency: "direct main"
description: description:
name: ffi name: ffi
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
+1
View File
@@ -32,6 +32,7 @@ dependencies:
sdk: flutter sdk: flutter
http: ^1.6.0 http: ^1.6.0
ffi: ^2.2.0
image_picker: ^1.2.2 image_picker: ^1.2.2
crypto: ^3.0.7 crypto: ^3.0.7
encrypt: ^5.0.3 encrypt: ^5.0.3
+23
View File
@@ -0,0 +1,23 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:ichino/core/date_time_format.dart';
void main() {
test('上海时区固定为 UTC+8', () {
expect(shanghaiOffset, const Duration(hours: 8));
expect(
shanghaiFromUnixSeconds(0),
DateTime.utc(1970, 1, 1, 8),
);
});
test('formatDate 补零', () {
expect(formatDate(DateTime(2026, 1, 5, 7, 3, 9)), '2026-01-05');
expect(formatDate(DateTime(2026, 12, 31, 23, 59, 59)), '2026-12-31');
});
test('formatDateTime 与 formatPlayDateTime 的秒与小数位', () {
final dt = DateTime(2026, 10, 2, 22, 19, 46);
expect(formatDateTime(dt), '2026-10-02 22:19:46');
expect(formatPlayDateTime(dt), '2026-10-02 22:19:46.0');
});
}
+84
View File
@@ -0,0 +1,84 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:ichino/core/json_value.dart';
void main() {
group('jsonInt', () {
test('int / num / 数字字符串都能读出来', () {
expect(jsonInt(12), 12);
expect(jsonInt(12.9), 12);
expect(jsonInt('42'), 42);
expect(jsonInt('-3'), -3);
});
test('null、非数字字符串、其他类型退回 fallback', () {
expect(jsonInt(null), 0);
expect(jsonInt('12abc'), 0);
expect(jsonInt('12abc', fallback: -1), -1);
expect(jsonInt({'v': 1}), 0);
});
});
group('jsonDouble', () {
test('int / double / 数字字符串都能读出来', () {
expect(jsonDouble(3), 3.0);
expect(jsonDouble(3.25), 3.25);
expect(jsonDouble('3.25'), 3.25);
});
test('解析失败退回 fallback', () {
expect(jsonDouble(null), 0.0);
expect(jsonDouble('x'), 0.0);
expect(jsonDouble('x', fallback: 1.5), 1.5);
});
});
group('jsonBool', () {
test('bool、数字、常见字符串形式', () {
expect(jsonBool(true), isTrue);
expect(jsonBool(false), isFalse);
expect(jsonBool(1), isTrue);
expect(jsonBool(0), isFalse);
expect(jsonBool('1'), isTrue);
expect(jsonBool(' TRUE '), isTrue);
expect(jsonBool('true'), isTrue);
expect(jsonBool('0'), isFalse);
expect(jsonBool('false'), isFalse);
});
test('无法判断时用 fallback', () {
expect(jsonBool(null), isFalse);
expect(jsonBool(null, fallback: true), isTrue);
expect(jsonBool('yes'), isFalse);
});
});
group('jsonString', () {
test('null 用 fallback,其他类型转字符串', () {
expect(jsonString(null), '');
expect(jsonString(null, fallback: '-'), '-');
expect(jsonString(7), '7');
expect(jsonString('abc'), 'abc');
});
});
group('jsonMap / jsonMapList', () {
test('jsonMap 只接受 Map<String, dynamic>', () {
final map = <String, dynamic>{'a': 1};
expect(jsonMap(map), same(map));
expect(jsonMap(null), isEmpty);
expect(jsonMap([1, 2]), isEmpty);
});
test('jsonMapList 丢掉非 Map 元素,其余原样保留', () {
final rows = [
<String, dynamic>{'id': 1},
'oops',
null,
<String, dynamic>{'id': 2},
];
expect(jsonMapList(rows).map((e) => e['id']), [1, 2]);
expect(jsonMapList(null), isEmpty);
expect(jsonMapList(<String, dynamic>{'id': 1}), isEmpty);
});
});
}
+78
View File
@@ -0,0 +1,78 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:ichino/config/strings.dart';
import 'package:ichino/config/title_server_config.dart';
import 'package:ichino/dev/ui_preview_data.dart';
import 'package:ichino/models/session_model.dart';
import 'package:ichino/pages/about_page.dart';
import 'package:ichino/pages/best50_page.dart';
import 'package:ichino/pages/high_risk_feature_page.dart';
import 'package:ichino/pages/home/home_tab_view.dart';
import 'package:ichino/pages/home_page.dart';
import 'package:ichino/pages/settings_page.dart';
import 'package:ichino/pages/ticket_page.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
setUp(() async {
SharedPreferences.setMockInitialValues({});
await TitleServerConfigHolder().clear();
SessionModel.instance.reset();
});
testWidgets('主页启动时只构建首页,其他页签首次访问才创建', (tester) async {
await tester.pumpWidget(
const MaterialApp(home: HomePage(userId: 1, token: 't')),
);
await tester.pump();
expect(find.byType(HomeTabView), findsOneWidget);
expect(find.byType(TicketPage), findsNothing);
expect(find.byType(Best50Page), findsNothing);
expect(find.byType(HighRiskFeaturePage), findsNothing);
expect(find.byType(SettingsPage), findsNothing);
expect(find.byType(AboutPage), findsNothing);
await tester.tap(find.byIcon(Icons.bar_chart_outlined));
await tester.pump();
expect(find.byType(Best50Page), findsOneWidget);
expect(find.widgetWithText(AppBar, AppStrings.tabBest50), findsOneWidget);
// 其余页签仍未构建。
expect(find.byType(TicketPage), findsNothing);
expect(find.byType(SettingsPage), findsNothing);
await tester.tap(find.byIcon(Icons.settings_outlined));
await tester.pump();
expect(find.byType(SettingsPage), findsOneWidget);
// 已访问过的页签保留在 IndexedStack 里,切换回来不重建。
expect(find.byType(Best50Page, skipOffstage: false), findsOneWidget);
expect(find.byType(TicketPage), findsNothing);
});
testWidgets('有预览种子数据时,未配置 Title Server 也展示主页数据', (tester) async {
tester.view.physicalSize = const Size(1280, 1600);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(
const MaterialApp(
home: HomePage(
userId: uiPreviewUserId,
token: uiPreviewToken,
restored: SessionRestoreResult(
reusedCookie: true,
userData: uiPreviewUserData,
preview: uiPreviewPreview,
loggedIn: true,
),
),
),
);
await tester.pump();
expect(find.text(AppStrings.titleServerNotConfigured), findsNothing);
expect(find.text('Ichino'), findsOneWidget);
});
}
+5 -2
View File
@@ -11,14 +11,17 @@ void main() {
await tester.pumpWidget(const MyApp()); await tester.pumpWidget(const MyApp());
await tester.pump(); await tester.pump();
// 72 位:截取的后 64 位里没有 SGWCMAID 前缀 // 72 位:既不是 84 位二维码串,也不是二维码链接
await tester.enterText(find.byType(TextField), 'ABCDEF' * 12); await tester.enterText(find.byType(TextField), 'ABCDEF' * 12);
await tester.pump(); await tester.pump();
await tester.tap(find.text(AppStrings.login)); await tester.tap(find.text(AppStrings.login));
await tester.pump(); await tester.pump();
expect(find.text(AppStrings.qrTokenUnrecognized), findsOneWidget); expect(
find.textContaining(AppStrings.qrTokenUnrecognized),
findsOneWidget,
);
}); });
testWidgets('粘贴连接信息时按钮切成「恢复会话」', (tester) async { testWidgets('粘贴连接信息时按钮切成「恢复会话」', (tester) async {
+88
View File
@@ -0,0 +1,88 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:ichino/config/title_server_config.dart';
import 'package:ichino/services/api_service.dart';
/// 扫码登录的输入归一化与 Keychip 拼装:纯逻辑,不发请求。
void main() {
const payload = 'A63E01C304710072610022219460000000000000000000000000000000000000';
const qrString = 'SGWCMAID261002221946$payload';
const qrUrl =
'https://wq.wahlap.net/qrcode/req/MAID261002221946$payload.html'
'?l=123456&t=261002221946&d=1';
group('normalizeQr / extractQRCode', () {
test('84 位二维码串取后 64 位载荷', () {
expect(payload.length, 64);
expect(qrString.length, 84);
expect(ApiService.extractQRCode(qrString), payload);
});
test('二维码链接还原成同一条串', () {
expect(ApiService.normalizeQr(qrUrl), qrString);
expect(ApiService.extractQRCode(qrUrl), payload);
});
test('长度、前缀、GameID、载荷字符集不合预期时抛 FormatException', () {
expect(
() => ApiService.normalizeQr('ABCDEF' * 12),
throwsFormatException,
);
expect(
() => ApiService.normalizeQr('XGWC${qrString.substring(4)}'),
throwsFormatException,
);
expect(
() => ApiService.normalizeQr('SGWCXBID${qrString.substring(8)}'),
throwsFormatException,
);
expect(
() => ApiService.normalizeQr('SGWCMAID261002221946${'G' * 64}'),
throwsFormatException,
);
expect(
() => ApiService.normalizeQr('https://wq.wahlap.net/other/x.html'),
throwsFormatException,
);
});
});
group('formatTimestamp', () {
test('本机本地墙上时间,yymmddHHMMss', () {
expect(
ApiService.formatTimestamp(DateTime(2026, 10, 2, 22, 19, 46)),
'261002221946',
);
expect(
ApiService.formatTimestamp(DateTime(2026, 1, 5, 7, 3, 9)),
'260105070309',
);
});
});
group('TitleServerConfig.fullKeychipId', () {
TitleServerConfig config(String keychip, {int placeId = 1007}) =>
TitleServerConfig(
titleServerUrl: 'https://example.invalid',
aesKey: 'k',
aesIv: 'v',
clientId: 'A63E01C3047',
placeId: placeId,
keychipId: keychip,
);
test('完整形式(带连字符 / 连写 / 小写)归一到大写带连字符', () {
expect(config('A63E-01C30471007').fullKeychipId, 'A63E-01C30471007');
expect(config('A63E01C30471007').fullKeychipId, 'A63E-01C30471007');
expect(config('a63e-01c30471007').fullKeychipId, 'A63E-01C30471007');
});
test('只填 11 位短值时按 placeId 补全', () {
expect(config('A63E01C3047').fullKeychipId, 'A63E-01C30471007');
});
test('短于 11 位当没配,交给登录层报错', () {
expect(config('').fullKeychipId, isEmpty);
expect(config('A63E01').fullKeychipId, isEmpty);
});
});
}
+92
View File
@@ -0,0 +1,92 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:ichino/config/strings.dart';
import 'package:ichino/widgets/risk/risk_list_widgets.dart';
Widget _wrap(Widget child) =>
MaterialApp(home: Scaffold(body: Column(children: [child])));
void main() {
testWidgets('RiskPendingList 空列表显示提示,非空显示行', (tester) async {
await tester.pumpWidget(
_wrap(
const RiskPendingList(
title: '待解锁',
emptyHint: '还没有添加',
count: 0,
rows: [],
),
),
);
expect(find.text('待解锁 (0)'), findsOneWidget);
expect(find.text('还没有添加'), findsOneWidget);
await tester.pumpWidget(
_wrap(
RiskPendingList(
title: '待解锁',
emptyHint: '还没有添加',
count: 1,
rows: const [
RiskPendingRow(leading: '#834', trailing: 'Master', onRemove: _noop),
],
),
),
);
expect(find.text('待解锁 (1)'), findsOneWidget);
expect(find.text('还没有添加'), findsNothing);
expect(find.textContaining('#834'), findsOneWidget);
});
testWidgets('RiskPendingRow 禁用时删除按钮不触发回调', (tester) async {
var removed = 0;
await tester.pumpWidget(
_wrap(
RiskPendingRow(
leading: '#1',
trailing: 'x',
onRemove: () => removed++,
enabled: false,
),
),
);
final button = tester.widget<IconButton>(find.byType(IconButton));
expect(button.onPressed, isNull);
expect(find.byTooltip(AppStrings.listRemoveTooltip), findsOneWidget);
await tester.pumpWidget(
_wrap(
RiskPendingRow(
leading: '#1',
trailing: 'x',
onRemove: () => removed++,
),
),
);
await tester.tap(find.byTooltip(AppStrings.listRemoveTooltip));
expect(removed, 1);
});
testWidgets('RiskNumberField 与 RiskAddButton 共用统一样式', (tester) async {
final controller = TextEditingController();
addTearDown(controller.dispose);
var added = 0;
await tester.pumpWidget(
_wrap(
Column(
children: [
RiskNumberField(controller: controller, label: 'A', hint: 'B'),
RiskAddButton(onPressed: () => added++),
],
),
),
);
expect(find.text('A'), findsOneWidget);
expect(find.text('B'), findsOneWidget);
await tester.tap(find.text(AppStrings.listAddButton));
expect(added, 1);
});
}
void _noop() {}
+43
View File
@@ -0,0 +1,43 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:ichino/models/session_model.dart';
void main() {
final session = SessionModel.instance;
setUp(session.reset);
tearDown(session.reset);
test('updateCookies 只在 Cookie 变化时通知,空值忽略', () {
var notified = 0;
session.addListener(() => notified++);
session.updateCookies(null);
session.updateCookies('');
expect(notified, 0);
expect(session.cookies, isNull);
session.updateCookies('JSESSIONID=A');
expect(session.cookies, 'JSESSIONID=A');
expect(notified, 1);
// 同一个值重复回写不制造额外重建。
session.updateCookies('JSESSIONID=A');
expect(notified, 1);
session.updateCookies('JSESSIONID=B');
expect(session.cookies, 'JSESSIONID=B');
expect(notified, 2);
});
test('reset 清空 Cookie 与登录态并通知', () {
session.updateCookies('JSESSIONID=A');
var notified = 0;
session.addListener(() => notified++);
session.reset();
expect(session.cookies, isNull);
expect(session.gameLogin, isNull);
expect(notified, 1);
});
}
+39
View File
@@ -0,0 +1,39 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:ichino/config/strings.dart';
import 'package:ichino/config/title_server_config.dart';
import 'package:ichino/models/session_model.dart';
import 'package:ichino/pages/settings_page.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
setUp(() async {
SharedPreferences.setMockInitialValues({});
await TitleServerConfigHolder().clear();
SessionModel.instance.reset();
});
testWidgets('设置页作为主页页签保存时不会弹掉根路由', (tester) async {
await tester.pumpWidget(
const MaterialApp(
home: Scaffold(body: SettingsPage(showAppBar: false)),
),
);
final fields = find.byType(TextFormField);
expect(fields, findsNWidgets(11));
for (var i = 0; i < 11; i++) {
await tester.enterText(fields.at(i), 'value');
}
final save = find.widgetWithText(FilledButton, AppStrings.save);
await tester.ensureVisible(save);
await tester.tap(save);
await tester.pumpAndSettle();
// 根路由还在,页面没有变成黑屏;改为用 SnackBar 提示已保存。
expect(find.byType(SettingsPage), findsOneWidget);
expect(find.text(AppStrings.configSaved), findsOneWidget);
expect(tester.state<NavigatorState>(find.byType(Navigator)).canPop(), isFalse);
});
}
+93
View File
@@ -0,0 +1,93 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:ichino/config/title_server_config.dart';
import 'package:ichino/services/title_api_service.dart';
const _config = TitleServerConfig(
titleServerUrl: 'http://127.0.0.1:9',
aesKey: '0123456789abcdef',
aesIv: 'fedcba9876543210',
clientId: 'A63E01C2805',
);
void main() {
test('describeApiError 只取协议层异常的 message', () {
expect(
describeApiError(const TitleApiException('GetUserDataApi returned 500')),
'GetUserDataApi returned 500',
);
expect(describeApiError(const FormatException('bad qr')), 'FormatException: bad qr');
expect(describeApiError('oops'), 'oops');
});
test('响应头里的 JSESSIONID 会写回实例并通知调用方', () async {
final captured = <String>[];
final sentCookies = <String?>[];
final service = TitleApiService(
_config,
cookies: 'JSESSIONID=OLD',
onCookiesChanged: captured.add,
);
var requests = 0;
final client = MockClient((request) async {
sentCookies.add(request.headers['Cookie']);
requests++;
if (requests == 1) {
return http.Response(
'',
500,
headers: {'Set-Cookie': 'JSESSIONID=NEW; Path=/; HttpOnly'},
);
}
return http.Response('', 500);
});
await http.runWithClient(() async {
// Cookie 捕获发生在状态码检查之前:即使这次请求失败,
// 服务器轮换的 Cookie 也不能丢。
await expectLater(
service.getUserData(1),
throwsA(isA<TitleApiException>()),
);
await expectLater(
service.getUserData(1),
throwsA(isA<TitleApiException>()),
);
}, () => client);
expect(captured, ['JSESSIONID=NEW']);
expect(service.cookies, 'JSESSIONID=NEW');
expect(sentCookies, ['JSESSIONID=OLD', 'JSESSIONID=NEW']);
});
test('响应没有 JSESSIONID 时不回写', () async {
final captured = <String>[];
final service = TitleApiService(
_config,
cookies: 'JSESSIONID=OLD',
onCookiesChanged: captured.add,
);
await http.runWithClient(() async {
await expectLater(
service.getUserData(1),
throwsA(isA<TitleApiException>()),
);
}, () => MockClient((_) async => http.Response('', 500)));
expect(captured, isEmpty);
expect(service.cookies, 'JSESSIONID=OLD');
});
test('lastRawResponse 是实例状态,不会跨实例串数据', () {
final first = TitleApiService(_config);
final second = TitleApiService(_config);
first.lastRawResponse = '{"userData":{}}';
expect(first.lastRawResponse, '{"userData":{}}');
expect(second.lastRawResponse, isNull);
});
}