Merge pull request #2 from AirFoundation/refactor/core-login
This commit is contained in:
commit
40f358b228
55 files changed
+4080
-3085
No files matched your search
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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` 失败。
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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',
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -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 [];
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -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(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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() {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import '../core/json_value.dart';
|
||||||
|
|
||||||
/// GetUserRatingApi 之外的角色(旅行伙伴)数据。
|
/// GetUserRatingApi 之外的角色(旅行伙伴)数据。
|
||||||
///
|
///
|
||||||
/// 对应客户端 `Net.VO.Mai2.UserCharacter`:只有 4 个字段,走
|
/// 对应客户端 `Net.VO.Mai2.UserCharacter`:只有 4 个字段,走
|
||||||
@@ -36,30 +38,18 @@ 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 是队长
|
||||||
|
|||||||
+42
-58
@@ -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']),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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();
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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,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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+111
-1040
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -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);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)}');
|
||||||
}
|
}
|
||||||
}(),
|
}(),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -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).
|
||||||
|
|||||||
@@ -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 中,否则服务器会静默丢弃整包处理。
|
||||||
|
|||||||
@@ -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,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,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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})',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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() {}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in new issue
Block a user