From e9357f5969a472ce94915fb920e7622510e6397a Mon Sep 17 00:00:00 2001 From: Bad0RANG3 <110911829+Bad0RANG3@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:51:10 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E6=8B=86=E5=88=86=E4=B8=BB?= =?UTF-8?q?=E9=A1=B5=E4=B8=8E=E5=85=AC=E5=85=B1=E7=BB=84=E4=BB=B6=EF=BC=8C?= =?UTF-8?q?=E6=89=AB=E7=A0=81=E7=99=BB=E5=BD=95=E6=94=B9=E8=B5=B0=20WinHTT?= =?UTF-8?q?P?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 抽出 lib/core 的日期/JSON 工具,收敛各页重复解析 - 主页与音乐风险页拆成组件,风险功能共用一套运行框架 - 修复设置页作为主页页签保存时弹掉根路由导致的黑屏 - 登录对齐 Astral aimedb 协议:84 位二维码校验、签名与请求头修正, 补 GlobalSign 中间证书;Windows 改用 WinHTTP 绕过服务端对 BoringSSL 的 TLS 指纹拦截(证书校验保持开启) - 补开发预览数据与测试 --- CLAUDE.md | 47 +- README.md | 41 +- lib/config/strings.dart | 12 +- lib/config/title_server_config.dart | 24 +- lib/core/date_time_format.dart | 22 + lib/core/json_value.dart | 38 + lib/dev/ui_preview_data.dart | 114 ++ lib/dev/ui_preview_main.dart | 22 + lib/main.dart | 7 +- lib/models/session_model.dart | 42 +- lib/models/user_character.dart | 30 +- lib/models/user_data.dart | 100 +- lib/models/user_kaleidx_scope.dart | 57 +- lib/models/user_preview.dart | 64 +- lib/models/user_rating.dart | 44 +- lib/pages/best50_page.dart | 11 +- lib/pages/high_risk_feature_page.dart | 282 ++--- lib/pages/home/home_tab.dart | 23 + lib/pages/home/home_tab_view.dart | 306 +++++ lib/pages/home_page.dart | 1169 ++------------------ lib/pages/kaleidx_scope_page.dart | 299 ++--- lib/pages/login_page.dart | 45 +- lib/pages/map_traverse_page.dart | 297 ++--- lib/pages/music_risk_feature_view.dart | 820 +++----------- lib/pages/number_patch_page.dart | 290 ++--- lib/pages/settings_page.dart | 33 +- lib/pages/ticket_page.dart | 215 ++-- lib/pages/travel_partner_page.dart | 626 +++++------ lib/services/account_backup_service.dart | 4 +- lib/services/aimedb_transport.dart | 374 +++++++ lib/services/api_service.dart | 197 +++- lib/services/music_data_service.dart | 21 +- lib/services/title_api_service.dart | 47 +- lib/services/user_all_payload_builder.dart | 31 +- lib/widgets/account_backup_card.dart | 7 +- lib/widgets/app_card.dart | 12 +- lib/widgets/home/home_common.dart | 243 ++++ lib/widgets/home/home_debug_card.dart | 111 ++ lib/widgets/home/home_preview_cards.dart | 47 + lib/widgets/home/home_user_data_cards.dart | 209 ++++ lib/widgets/risk/music_risk_editors.dart | 232 ++++ lib/widgets/risk/risk_list_widgets.dart | 186 ++++ lib/widgets/risk/risk_run_mixin.dart | 96 ++ lib/widgets/risk/risk_widgets.dart | 332 ++++++ pubspec.lock | 2 +- pubspec.yaml | 1 + test/core/date_time_format_test.dart | 23 + test/core/json_value_test.dart | 84 ++ test/home_lazy_tabs_test.dart | 78 ++ test/login_page_test.dart | 7 +- test/qr_login_test.dart | 88 ++ test/risk_widgets_test.dart | 92 ++ test/session_model_test.dart | 43 + test/settings_tab_test.dart | 39 + test/title_api_service_test.dart | 93 ++ 55 files changed, 4387 insertions(+), 3392 deletions(-) create mode 100644 lib/core/date_time_format.dart create mode 100644 lib/core/json_value.dart create mode 100644 lib/dev/ui_preview_data.dart create mode 100644 lib/dev/ui_preview_main.dart create mode 100644 lib/pages/home/home_tab.dart create mode 100644 lib/pages/home/home_tab_view.dart create mode 100644 lib/services/aimedb_transport.dart create mode 100644 lib/widgets/home/home_common.dart create mode 100644 lib/widgets/home/home_debug_card.dart create mode 100644 lib/widgets/home/home_preview_cards.dart create mode 100644 lib/widgets/home/home_user_data_cards.dart create mode 100644 lib/widgets/risk/music_risk_editors.dart create mode 100644 lib/widgets/risk/risk_list_widgets.dart create mode 100644 lib/widgets/risk/risk_run_mixin.dart create mode 100644 lib/widgets/risk/risk_widgets.dart create mode 100644 test/core/date_time_format_test.dart create mode 100644 test/core/json_value_test.dart create mode 100644 test/home_lazy_tabs_test.dart create mode 100644 test/qr_login_test.dart create mode 100644 test/risk_widgets_test.dart create mode 100644 test/session_model_test.dart create mode 100644 test/settings_tab_test.dart create mode 100644 test/title_api_service_test.dart diff --git a/CLAUDE.md b/CLAUDE.md index 6e323dc..edd15e4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,16 +41,31 @@ login client for the maimai DX arcade game network. ``` lib/ ├── 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/ │ ├── 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 / 机器参数配置 │ └── ... # 各功能页(ticket / best50 / 风险操作等) +├── models/ +│ ├── session_model.dart # 会话与 Cookie 的唯一持有者(ChangeNotifier 单例) +│ └── ... ├── widgets/ │ ├── cooldown_mixin.dart # CooldownMixin:登录后冷却倒计时 │ ├── step_progress.dart # RiskStep + StepProgressCard │ ├── app_card.dart # AppCard / SectionTitle / AutoLogoutToggle │ ├── app_notice.dart # AppNotice + context.showSnack +│ ├── home/ # 主页资料 / 统计 / 调试卡片 +│ ├── risk/ +│ │ ├── risk_list_widgets.dart # 待提交列表与表单控件 +│ │ ├── music_risk_editors.dart # 解锁歌曲 / 收藏品编辑器 +│ │ └── risk_run_mixin.dart # 风险执行阶段与错误上报 │ └── best50_poster.dart # B50 成绩图 └── services/ ├── api_service.dart # Aime 登录 API 客户端 @@ -69,13 +84,35 @@ lib/ - **QR decoding is native-only**: `services/qr_service.dart` calls the `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. -- **Auth flow**: `ApiService.login()` takes a QR code token (last 64 chars), builds a SHA-256 key - from `chipID + JST-timestamp + chimeSalt`, and POSTs JSON to the configured Aime URL. A successful - response has `errorID: 0` and returns `userID` + `token`. +- **Auth flow**: `ApiService.login()` is a port of Astral's `api/Chime.py`. It normalizes the pasted + input (84-char `SGWCMAID…` string, or a `wq.wahlap.net/qrcode/req/….html` link) and POSTs + `{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 `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. -- **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 diff --git a/README.md b/README.md index 210f76e..b68dc1c 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Android / iOS / Windows / macOS / Linux。 | 票据 | 功能票查询与使用 | | B50 | Best 50 成绩图,可导出 PNG(移植自 Empurple 的 `Best50ImageRenderer`) | | 风险 | 歌曲解锁、收藏品获取、旅行伙伴发放与编组(含等级)、修改总 Rating、添加舞里程、一键跑图、万花筒专区 | -| 设置 | Title Server / Auth Server / 机器参数,支持配置导入导出 | +| 设置 | Title Server / 机器参数 / Keychip,支持配置导入导出 | | 连接信息(实验性) | 主页导出 uid + JSESSIONID + token 的 Base64 快照,登录页粘贴即可恢复会话;内容未加密,请勿外传 | | 账号备份 | 主页只读地把服务器上的用户数据汇成一串 JSON(道具、舞里程、游玩统计、旅行伙伴、区域与万花筒进度、Rating/成绩)。全程只发 `GetUser*Api`,不写任何数据;单段读失败不整体作废,失败段列在结果里 | @@ -45,10 +45,16 @@ flutter build ios --release --no-codesign # iOS(无证书时只验证可编 lib/ ├── main.dart # 入口(MyApp + MaterialApp) ├── config/ # 字符串、响应式布局、Title Server 配置 -├── models/ # API 数据模型(UserData / UserPreview / UserRating / UserCharacter / UserKaleidxScope / Session) -├── pages/ # 登录页与各功能页面 +├── core/ # 宽松 JSON 解析、UTC+8 时间格式化 +├── models/ # API 数据模型与 SessionModel(会话 / Cookie 的唯一持有者) +├── pages/ +│ ├── home/ # 主页页签枚举与首页视图(HomeTab / HomeTabView) +│ └── ... # 登录页与各功能页面 ├── services/ # 传输层、payload 组装、乐曲元数据、导出、RA 计算、调试日志 -└── widgets/ # 可复用视觉组件(B50 海报、卡片/提示、冷却 mixin、阶段进度卡) +└── widgets/ + ├── home/ # 主页头像、资料、统计、调试卡片 + ├── risk/ # 风险页共享脚手架、列表控件与编辑器 + └── ... # 可复用视觉组件(B50 海报、卡片/提示、冷却 mixin、阶段进度卡) tools/ # 离线开发工具(不参与构建产物) 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/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_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 - 供 payload builder 复用。 + 供 payload builder 复用;`describeApiError()` 统一面向用户的错误文本。 - `services/user_all_payload_builder.dart` — 占位 `musicData`(`placeholderMusicData`)。 - `services/api_log.dart` —`ApiLog`:传输层日志,仅 debug 输出,release 下不落 token/cookie。 +主页的六个页签由 `HomeTab` 枚举驱动,`IndexedStack` 只构建访问过的页签, +票据 / B50 / 风险 hub 不会再在启动时各自发一轮请求。 `TitleApiService.lastRawResponse` +是实例状态,`SessionModel` 把预览原文透传给主页调试卡,不会跨请求串数据。 + 平台差异只体现在原生侧:QR 解码由 Android 的 `MainActivity` 与 iOS 的 `SceneDelegate` 通过 `dev.naominet.ichino/qr_scanner` 这个 MethodChannel 提供, 桌面平台没有该 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 路径、 - JSON → ZLib → AES-CBC 编解码、`Set-Cookie` 会话维持。时间戳用 JST(UTC+9)。 + JSON → ZLib → AES-CBC 编解码、`Set-Cookie` 会话维持。 - **B50 成绩图**:底图 `assets/b50/base.png`(1762×1850)之上按固定像素坐标叠加头像、 昵称、50 张卡片与页脚;卡片 300×100、每行 5 张,BEST35 从 y=350 起、BEST15 固定从 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` 也声明成 `ffiPlugin`,只要它出现在依赖树里,桌面构建就会去链接 `jvm.lib` 并失败。 `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, 而 JBR 不含 `jlink.exe`,会导致 `:flutter_plugin_android_lifecycle:compileReleaseJavaWithJavac` 失败。 diff --git a/lib/config/strings.dart b/lib/config/strings.dart index 7f20636..cbf726b 100644 --- a/lib/config/strings.dart +++ b/lib/config/strings.dart @@ -12,7 +12,7 @@ class AppStrings { // Login static const appTitle = 'Ichino'; static const qrCodeToken = 'QR Code 令牌'; - static const qrHint = '粘贴二维码解析内容... / 粘贴连接信息'; + static const qrHint = '粘贴二维码解析内容或二维码链接... / 粘贴连接信息'; static const uploadQR = '上传二维码'; static const login = '登录'; static const loggingIn = '登录中...'; @@ -21,7 +21,7 @@ class AppStrings { static const qrScanError = '解析失败'; static const forcePreviewApi = '强制使用 Preview API'; static const loginFailed = '登录失败'; - static const qrTokenUnrecognized = '没认出 QR 令牌(内容里应当含 SGWCMAID),也可以粘贴「连接信息」。'; + static const qrTokenUnrecognized = '没认出 QR 令牌'; static const requestFailed = '请求失败'; static const settingsTooltip = '设置'; @@ -164,6 +164,7 @@ class AppStrings { static const authServerSettings = 'Auth Server'; static const machineSettings = '机器信息'; static const save = '保存'; + static const configSaved = '配置已保存'; static const labelTitleServerUrl = 'Title Server URL'; static const hintTitleServerUrl = ''; static const labelAesKey = 'AES Key'; @@ -185,11 +186,8 @@ class AppStrings { static const labelPlaceName = 'Place 名称'; static const hintPlaceName = 'Place name'; static const labelKeychipId = 'Keychip ID'; - static const hintKeychipId = 'ID'; - static const labelAimeUrl = 'Aime URL'; - static const hintAimeUrl = ''; - static const labelAimeSalt = 'Aime Salt'; - static const hintAimeSalt = 'API Key 派生用 Salt'; + static const helpKeychipId = + '机台完整 Keychip(如 A63E-01C30471007);只填 11 位短值时按 Place ID 补全'; static String fieldRequired(String label) => '$label 不能为空'; // Config Export/Import diff --git a/lib/config/title_server_config.dart b/lib/config/title_server_config.dart index 684f8c9..5932abd 100644 --- a/lib/config/title_server_config.dart +++ b/lib/config/title_server_config.dart @@ -14,9 +14,6 @@ class TitleServerConfig { final String regionName; final String placeName; final String keychipId; - final String aimeUrl; - final String aimeSalt; - final String openGameID; const TitleServerConfig({ required this.titleServerUrl, @@ -30,14 +27,21 @@ class TitleServerConfig { this.regionName = '', this.placeName = '', this.keychipId = '', - this.aimeUrl = '', - this.aimeSalt = '', - String? openGameID, - }) : openGameID = openGameID ?? 'MAID'; + }); List get aesKeyBytes => utf8.encode(aesKey); List 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 toJson() => { 'titleServerUrl': titleServerUrl, 'aesKey': aesKey, @@ -50,9 +54,6 @@ class TitleServerConfig { 'regionName': regionName, 'placeName': placeName, 'keychipId': keychipId, - 'aimeUrl': aimeUrl, - 'aimeSalt': aimeSalt, - 'openGameID': openGameID, }; factory TitleServerConfig.fromJson(Map json) { @@ -68,9 +69,6 @@ class TitleServerConfig { regionName: json['regionName'] as String? ?? '', placeName: json['placeName'] as String? ?? '', keychipId: json['keychipId'] as String? ?? '', - aimeUrl: json['aimeUrl'] as String? ?? '', - aimeSalt: json['aimeSalt'] as String? ?? '', - openGameID: json['openGameID'] as String? ?? 'MAID', ); } } diff --git a/lib/core/date_time_format.dart b/lib/core/date_time_format.dart new file mode 100644 index 0000000..19f9db8 --- /dev/null +++ b/lib/core/date_time_format.dart @@ -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'; diff --git a/lib/core/json_value.dart b/lib/core/json_value.dart new file mode 100644 index 0000000..a10c587 --- /dev/null +++ b/lib/core/json_value.dart @@ -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 jsonMap(dynamic value) => + value is Map ? value : const {}; + +List> jsonMapList(dynamic value) => value is List + ? value.whereType>().toList(growable: false) + : const []; diff --git a/lib/dev/ui_preview_data.dart b/lib/dev/ui_preview_data.dart new file mode 100644 index 0000000..54dcee6 --- /dev/null +++ b/lib/dev/ui_preview_data.dart @@ -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, + ); +} diff --git a/lib/dev/ui_preview_main.dart b/lib/dev/ui_preview_main.dart new file mode 100644 index 0000000..fec67b9 --- /dev/null +++ b/lib/dev/ui_preview_main.dart @@ -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, + ), + ), + ); +} diff --git a/lib/main.dart b/lib/main.dart index 55b9493..788e04a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -11,7 +11,10 @@ void main() async { } class MyApp extends StatelessWidget { - const MyApp({super.key}); + /// 默认从登录页启动;开发预览等入口可传入别的根页面。 + final Widget? home; + + const MyApp({super.key, this.home}); @override Widget build(BuildContext context) { @@ -31,7 +34,7 @@ class MyApp extends StatelessWidget { brightness: Brightness.dark, ), ), - home: const LoginPage(), + home: home ?? const LoginPage(), ); } } diff --git a/lib/models/session_model.dart b/lib/models/session_model.dart index 6cc74fe..909405d 100644 --- a/lib/models/session_model.dart +++ b/lib/models/session_model.dart @@ -17,6 +17,9 @@ class SessionRestoreResult { final UserDataBean? userData; final UserPreviewDataBean? preview; + /// 恢复过程中 GetUserPreviewApi 的响应原文,供主页调试卡展示。 + final String? rawPreviewResponse; + /// 服务器认为这次会话仍在登录中(`preview.isLogin`),或刚用令牌登录成功。 /// 只有为 `true` 时票据与风险页才开放写入,并且照常吃 60 秒登录后冷却。 final bool loggedIn; @@ -25,6 +28,7 @@ class SessionRestoreResult { required this.reusedCookie, this.userData, this.preview, + this.rawPreviewResponse, this.loggedIn = false, }); } @@ -44,11 +48,22 @@ class SessionModel extends ChangeNotifier { UserLoginResult? get gameLogin => _gameLogin; String? get cookies => _cookies; - static String extractQRCode(String qrCodeToken) => - ApiService.extractQRCode(qrCodeToken); + /// 构造会回写 Cookie 的服务。 + /// + /// 生产环境优先用会话里最新的 JSESSIONID:Title Server 可能在任意响应里 + /// 轮换 Cookie,页面若一直拿着进入时的快照,后续请求就会带着旧值。 + /// 直接构造页面的测试没有会话,才退回调用方传入的 [cookies]。 + TitleApiService? createTitleService({String? cookies}) { + return TitleApiService.fromHolder( + cookies: _cookies ?? cookies, + onCookiesChanged: updateCookies, + ); + } - Future loginWithQr(String qrCodeToken) async { - final result = await _apiService.login(qrCodeToken); + /// [qrInput] 可以是 84 位二维码串,也可以是主页导出的二维码链接; + /// 格式不合预期时 [ApiService.login] 抛 [FormatException]。 + Future loginWithQr(String qrInput) async { + final result = await _apiService.login(qrInput); if (result.success) { _loginResult = result; notifyListeners(); @@ -79,7 +94,11 @@ class SessionModel extends ChangeNotifier { final config = TitleServerConfigHolder().config; if (config == null) return; - final service = TitleApiService(config, cookies: cookies ?? _cookies); + final service = TitleApiService( + config, + cookies: cookies ?? _cookies, + onCookiesChanged: updateCookies, + ); try { await service.userLogout( userId: userId, @@ -136,7 +155,10 @@ class SessionModel extends ChangeNotifier { SessionShare share, String cookie, ) async { - final service = TitleApiService.fromHolder(cookies: cookie); + final service = TitleApiService.fromHolder( + cookies: cookie, + onCookiesChanged: updateCookies, + ); if (service == null) return null; try { @@ -159,6 +181,7 @@ class SessionModel extends ChangeNotifier { } } updateCookies(service.cookies); + final rawPreviewResponse = service.lastRawResponse; // 服务器说这次会话还挂在机上,就按已登录处理:loginDateTime 取当下, // 风险页与票据照常吃「登录后 60 秒冷却」,不用重新 UserLoginApi。 @@ -177,6 +200,7 @@ class SessionModel extends ChangeNotifier { reusedCookie: true, userData: userData, preview: preview, + rawPreviewResponse: rawPreviewResponse, loggedIn: _gameLogin != null, ); } on TitleApiException { @@ -185,9 +209,9 @@ class SessionModel extends ChangeNotifier { } void updateCookies(String? cookies) { - if (cookies != null && cookies.isNotEmpty) { - _cookies = cookies; - } + if (cookies == null || cookies.isEmpty || cookies == _cookies) return; + _cookies = cookies; + notifyListeners(); } void reset() { diff --git a/lib/models/user_character.dart b/lib/models/user_character.dart index 093b5fb..19c46ea 100644 --- a/lib/models/user_character.dart +++ b/lib/models/user_character.dart @@ -1,3 +1,5 @@ +import '../core/json_value.dart'; + /// GetUserRatingApi 之外的角色(旅行伙伴)数据。 /// /// 对应客户端 `Net.VO.Mai2.UserCharacter`:只有 4 个字段,走 @@ -36,30 +38,18 @@ class UserCharacterBean { useCount: useCount ?? this.useCount, ); - static int _toInt(dynamic v) { - if (v == null) return 0; - if (v is int) return v; - if (v is num) return v.toInt(); - if (v is String) return int.tryParse(v) ?? 0; - return 0; - } - factory UserCharacterBean.fromJson(Map json) => UserCharacterBean( - characterId: _toInt(json['characterId']), - level: _toInt(json['level']), - awakening: _toInt(json['awakening']), - useCount: _toInt(json['useCount']), + characterId: jsonInt(json['characterId']), + level: jsonInt(json['level']), + awakening: jsonInt(json['awakening']), + useCount: jsonInt(json['useCount']), ); - static List listFromResponse(Map json) { - final raw = json['userCharacterList']; - if (raw is! List) return const []; - return raw - .whereType>() - .map(UserCharacterBean.fromJson) - .toList(); - } + static List listFromResponse(Map json) => + jsonMapList(json['userCharacterList']) + .map(UserCharacterBean.fromJson) + .toList(); } /// `UserDetail.charaSlot` 固定 5 个槽,槽 0 是队长 diff --git a/lib/models/user_data.dart b/lib/models/user_data.dart index 7f36e61..9048eba 100644 --- a/lib/models/user_data.dart +++ b/lib/models/user_data.dart @@ -1,3 +1,5 @@ +import '../core/json_value.dart'; + class UserDataBean { final int userId; final String userName; @@ -84,67 +86,49 @@ class UserDataBean { }); factory UserDataBean.fromJson(Map json) { - final ud = (json['userData'] as Map?) ?? const {}; - - int toInt(dynamic v) { - if (v == null) return 0; - if (v is int) return v; - if (v is num) return v.toInt(); - if (v is String) return int.tryParse(v) ?? 0; - return 0; - } - - bool toBool(dynamic v) { - if (v == null) return false; - if (v is bool) return v; - if (v is num) return v != 0; - if (v is String) return v == '1' || v.toLowerCase() == 'true'; - return false; - } - - String toStr(dynamic v) => v?.toString() ?? ''; + final ud = jsonMap(json['userData']); return UserDataBean( - userId: toInt(json['userId']), - userName: toStr(ud['userName']), + userId: jsonInt(json['userId']), + userName: jsonString(ud['userName']), friendCode: ud['friendCode']?.toString(), - isNetMember: toBool(ud['isNetMember']), - point: toInt(ud['point']), - totalPoint: toInt(ud['totalPoint']), - playerRating: toInt(ud['playerRating']), - playerOldRating: toInt(ud['playerOldRating']), - playerNewRating: toInt(ud['playerNewRating']), - highestRating: toInt(ud['highestRating']), - musicRating: toInt(ud['musicRating']), - gradeRank: toInt(ud['gradeRank']), - courseRank: toInt(ud['courseRank']), - classRank: toInt(ud['classRank']), - nameplateId: toInt(ud['nameplateId']), - frameId: toInt(ud['frameId']), - iconId: toInt(ud['iconId']), - trophyId: toInt(ud['trophyId']), - plateId: toInt(ud['plateId']), - titleId: toInt(ud['titleId']), - partnerId: toInt(ud['partnerId']), - playCount: toInt(ud['playCount']), - currentPlayCount: toInt(ud['currentPlayCount']), - totalSync: toInt(ud['totalSync']), - totalAchievement: toInt(ud['totalAchievement']), - totalDeluxscore: toInt(ud['totalDeluxscore']), - totalAwake: toInt(ud['totalAwake']), - lastGameId: toStr(ud['lastGameId']), - lastRomVersion: toStr(ud['lastRomVersion']), - lastDataVersion: toStr(ud['lastDataVersion']), - lastLoginDate: toStr(ud['lastLoginDate']), - lastPlayDate: toStr(ud['lastPlayDate']), - firstGameId: toStr(ud['firstGameId']), - firstRomVersion: toStr(ud['firstRomVersion']), - firstDataVersion: toStr(ud['firstDataVersion']), - firstPlayDate: toStr(ud['firstPlayDate']), - lastRegionName: toStr(ud['lastRegionName']), - lastRegionId: toInt(ud['lastRegionId']), - dailyBonusDate: toStr(ud['dailyBonusDate']), - banState: toInt(json['banState']), + isNetMember: jsonBool(ud['isNetMember']), + point: jsonInt(ud['point']), + totalPoint: jsonInt(ud['totalPoint']), + playerRating: jsonInt(ud['playerRating']), + playerOldRating: jsonInt(ud['playerOldRating']), + playerNewRating: jsonInt(ud['playerNewRating']), + highestRating: jsonInt(ud['highestRating']), + musicRating: jsonInt(ud['musicRating']), + gradeRank: jsonInt(ud['gradeRank']), + courseRank: jsonInt(ud['courseRank']), + classRank: jsonInt(ud['classRank']), + nameplateId: jsonInt(ud['nameplateId']), + frameId: jsonInt(ud['frameId']), + iconId: jsonInt(ud['iconId']), + trophyId: jsonInt(ud['trophyId']), + plateId: jsonInt(ud['plateId']), + titleId: jsonInt(ud['titleId']), + partnerId: jsonInt(ud['partnerId']), + playCount: jsonInt(ud['playCount']), + currentPlayCount: jsonInt(ud['currentPlayCount']), + totalSync: jsonInt(ud['totalSync']), + totalAchievement: jsonInt(ud['totalAchievement']), + totalDeluxscore: jsonInt(ud['totalDeluxscore']), + totalAwake: jsonInt(ud['totalAwake']), + lastGameId: jsonString(ud['lastGameId']), + lastRomVersion: jsonString(ud['lastRomVersion']), + lastDataVersion: jsonString(ud['lastDataVersion']), + lastLoginDate: jsonString(ud['lastLoginDate']), + lastPlayDate: jsonString(ud['lastPlayDate']), + firstGameId: jsonString(ud['firstGameId']), + firstRomVersion: jsonString(ud['firstRomVersion']), + firstDataVersion: jsonString(ud['firstDataVersion']), + firstPlayDate: jsonString(ud['firstPlayDate']), + lastRegionName: jsonString(ud['lastRegionName']), + lastRegionId: jsonInt(ud['lastRegionId']), + dailyBonusDate: jsonString(ud['dailyBonusDate']), + banState: jsonInt(json['banState']), ); } } \ No newline at end of file diff --git a/lib/models/user_kaleidx_scope.dart b/lib/models/user_kaleidx_scope.dart index 5493a3b..cc09607 100644 --- a/lib/models/user_kaleidx_scope.dart +++ b/lib/models/user_kaleidx_scope.dart @@ -1,3 +1,5 @@ +import '../core/json_value.dart'; + /// `Net.VO.Mai2.UserKaleidxScope`(万花筒 / 宿命之门),走 /// `upsertUserAll.userKaleidxScopeList`,主键是 `gateId`。 /// @@ -130,47 +132,28 @@ class UserKaleidxScopeBean { : ''), ); - static int _toInt(dynamic v) { - if (v is int) return v; - if (v is num) return v.toInt(); - if (v is String) return int.tryParse(v) ?? 0; - return 0; - } - - static bool _toBool(dynamic v) { - if (v is bool) return v; - if (v is num) return v != 0; - if (v is String) return v == '1' || v.toLowerCase() == 'true'; - return false; - } - factory UserKaleidxScopeBean.fromJson(Map json) => UserKaleidxScopeBean( - gateId: _toInt(json['gateId']), - isGateFound: _toBool(json['isGateFound']), - isKeyFound: _toBool(json['isKeyFound']), - isClear: _toBool(json['isClear']), - totalRestLife: _toInt(json['totalRestLife']), - totalAchievement: _toInt(json['totalAchievement']), - totalDeluxscore: _toInt(json['totalDeluxscore']), - bestAchievement: _toInt(json['bestAchievement']), - bestDeluxscore: _toInt(json['bestDeluxscore']), - bestAchievementDate: json['bestAchievementDate']?.toString() ?? '', - bestDeluxscoreDate: json['bestDeluxscoreDate']?.toString() ?? '', - playCount: _toInt(json['playCount']), - clearDate: json['clearDate']?.toString() ?? '', - lastPlayDate: json['lastPlayDate']?.toString() ?? '', - isInfoWatched: _toBool(json['isInfoWatched']), + gateId: jsonInt(json['gateId']), + isGateFound: jsonBool(json['isGateFound']), + isKeyFound: jsonBool(json['isKeyFound']), + isClear: jsonBool(json['isClear']), + totalRestLife: jsonInt(json['totalRestLife']), + totalAchievement: jsonInt(json['totalAchievement']), + totalDeluxscore: jsonInt(json['totalDeluxscore']), + bestAchievement: jsonInt(json['bestAchievement']), + bestDeluxscore: jsonInt(json['bestDeluxscore']), + bestAchievementDate: jsonString(json['bestAchievementDate']), + bestDeluxscoreDate: jsonString(json['bestDeluxscoreDate']), + playCount: jsonInt(json['playCount']), + clearDate: jsonString(json['clearDate']), + lastPlayDate: jsonString(json['lastPlayDate']), + isInfoWatched: jsonBool(json['isInfoWatched']), ); static List listFromResponse( Map json, - ) { - final raw = json['userKaleidxScopeList']; - if (raw is! List) return const []; - return raw - .whereType>() - .map(UserKaleidxScopeBean.fromJson) - .toList(); - } + ) => jsonMapList(json['userKaleidxScopeList']) + .map(UserKaleidxScopeBean.fromJson) + .toList(); } diff --git a/lib/models/user_preview.dart b/lib/models/user_preview.dart index 6325011..9f6e1ce 100644 --- a/lib/models/user_preview.dart +++ b/lib/models/user_preview.dart @@ -1,3 +1,5 @@ +import '../core/json_value.dart'; + class UserPreviewDataBean { final int userId; final String userName; @@ -49,48 +51,28 @@ class UserPreviewDataBean { }); factory UserPreviewDataBean.fromJson(Map json) { - int toInt(dynamic v) { - if (v == null) return 0; - if (v is int) return v; - if (v is String) return int.tryParse(v) ?? 0; - if (v is double) return v.toInt(); - return 0; - } - - bool toBool(dynamic v) { - if (v == null) return false; - if (v is bool) return v; - if (v is int) return v != 0; - if (v is String) return v == '1' || v.toLowerCase() == 'true'; - return false; - } - - String toStr(dynamic v) => v?.toString() ?? ''; - return UserPreviewDataBean( - userId: toInt(json['userId']), - userName: toStr(json['userName']), - isLogin: toBool(json['isLogin']), - loginId: toInt(json['loginId']), - lastGameId: toStr(json['lastGameId']), - lastRomVersion: toStr(json['lastRomVersion']), - lastDataVersion: toStr(json['lastDataVersion']), - lastLoginDate: toStr(json['lastLoginDate']), - lastPlayDate: toStr(json['lastPlayDate']), - playerRating: toInt(json['playerRating']), - nameplateId: toInt(json['nameplateId']), - iconId: toInt(json['iconId']), - trophyId: toInt(json['trophyId']), - isNetMember: toBool(json['isNetMember']), - isInherit: toBool(json['isInherit']), - totalAwake: toInt(json['totalAwake']), - dispRate: toInt(json['dispRate']), - dailyBonusDate: toStr(json['dailyBonusDate']), - headPhoneVolume: (json['headPhoneVolume'] is String) - ? (int.tryParse(json['headPhoneVolume']) ?? 0) - : toInt(json['headPhoneVolume']), - banState: toInt(json['banState']), - errorId: toInt(json['errorId']), + userId: jsonInt(json['userId']), + userName: jsonString(json['userName']), + isLogin: jsonBool(json['isLogin']), + loginId: jsonInt(json['loginId']), + lastGameId: jsonString(json['lastGameId']), + lastRomVersion: jsonString(json['lastRomVersion']), + lastDataVersion: jsonString(json['lastDataVersion']), + lastLoginDate: jsonString(json['lastLoginDate']), + lastPlayDate: jsonString(json['lastPlayDate']), + playerRating: jsonInt(json['playerRating']), + nameplateId: jsonInt(json['nameplateId']), + iconId: jsonInt(json['iconId']), + trophyId: jsonInt(json['trophyId']), + isNetMember: jsonBool(json['isNetMember']), + isInherit: jsonBool(json['isInherit']), + totalAwake: jsonInt(json['totalAwake']), + dispRate: jsonInt(json['dispRate']), + dailyBonusDate: jsonString(json['dailyBonusDate']), + headPhoneVolume: jsonInt(json['headPhoneVolume']), + banState: jsonInt(json['banState']), + errorId: jsonInt(json['errorId']), ); } } diff --git a/lib/models/user_rating.dart b/lib/models/user_rating.dart index 0eba4bd..ae447ec 100644 --- a/lib/models/user_rating.dart +++ b/lib/models/user_rating.dart @@ -1,3 +1,5 @@ +import '../core/json_value.dart'; + /// GetUserRatingApi 的 `userRating` 结构,移植自 Empurple 的 `UserRatingData`。 /// /// 服务端已按 RA 排好 BEST 35 / BEST 15,客户端不再重排。 @@ -43,21 +45,13 @@ class UserRatingItemBean { MusicLevel get musicLevel => MusicLevel.fromInt(level); factory UserRatingItemBean.fromJson(Map json) { - int toInt(dynamic v) { - if (v == null) return 0; - if (v is int) return v; - if (v is num) return v.toInt(); - if (v is String) return int.tryParse(v) ?? 0; - return 0; - } - return UserRatingItemBean( - musicId: toInt(json['musicId']), - level: toInt(json['level']), - achievement: toInt(json['achievement']), - comboStatus: toInt(json['comboStatus']), - syncStatus: toInt(json['syncStatus']), - romVersion: toInt(json['romVersion']), + musicId: jsonInt(json['musicId']), + level: jsonInt(json['level']), + achievement: jsonInt(json['achievement']), + comboStatus: jsonInt(json['comboStatus']), + syncStatus: jsonInt(json['syncStatus']), + romVersion: jsonInt(json['romVersion']), ); } } @@ -73,28 +67,14 @@ class UserRatingBean { required this.newRatingList, }); - static List _parseList(dynamic raw) { - if (raw is! List) return const []; - return raw - .whereType>() - .map(UserRatingItemBean.fromJson) - .toList(); - } + static List _parseList(dynamic raw) => + jsonMapList(raw).map(UserRatingItemBean.fromJson).toList(); factory UserRatingBean.fromJson(Map json) { - int toInt(dynamic v) { - if (v == null) return 0; - if (v is int) return v; - if (v is num) return v.toInt(); - if (v is String) return int.tryParse(v) ?? 0; - return 0; - } - - final userRating = - (json['userRating'] as Map?) ?? const {}; + final userRating = jsonMap(json['userRating']); return UserRatingBean( - userId: toInt(json['userId']), + userId: jsonInt(json['userId']), ratingList: _parseList(userRating['ratingList']), newRatingList: _parseList(userRating['newRatingList']), ); diff --git a/lib/pages/best50_page.dart b/lib/pages/best50_page.dart index 01d4278..1c1239a 100644 --- a/lib/pages/best50_page.dart +++ b/lib/pages/best50_page.dart @@ -6,6 +6,7 @@ import 'package:flutter/rendering.dart'; import '../config/responsive.dart'; import '../config/strings.dart'; import '../config/title_server_config.dart'; +import '../models/session_model.dart'; import '../models/user_rating.dart'; import '../services/music_data_service.dart'; import '../services/png_export_service.dart'; @@ -56,7 +57,7 @@ class _Best50PageState extends State { @override void didUpdateWidget(Best50Page oldWidget) { super.didUpdateWidget(oldWidget); - // 本页在 IndexedStack 里会先于 preview/登录请求被构建,cookies 到位后补一次加载。 + // 用户可能在 preview/登录请求完成前就切到本页,cookies 到位后再补一次加载。 if (oldWidget.cookies != widget.cookies && (_rating == null || _error != null)) { _bootstrap(); @@ -75,7 +76,9 @@ class _Best50PageState extends State { }); try { - final service = TitleApiService.fromHolder(cookies: widget.cookies)!; + final service = SessionModel.instance.createTitleService( + cookies: widget.cookies, + )!; final rating = await service.getUserRatingTyped(widget.userId); if (!mounted) return; setState(() { @@ -87,10 +90,8 @@ class _Best50PageState extends State { await MusicDataService.instance.load(); if (!mounted) return; setState(() => _musicReady = true); - } on TitleApiException catch (e) { - _fail(e.message); } catch (e) { - _fail('$e'); + _fail(describeApiError(e)); } } diff --git a/lib/pages/high_risk_feature_page.dart b/lib/pages/high_risk_feature_page.dart index 473bafa..d669488 100644 --- a/lib/pages/high_risk_feature_page.dart +++ b/lib/pages/high_risk_feature_page.dart @@ -1,10 +1,9 @@ import 'package:flutter/material.dart'; -import '../config/responsive.dart'; import '../config/strings.dart'; import '../config/title_server_config.dart'; -import '../widgets/app_notice.dart'; import '../widgets/cooldown_mixin.dart'; +import '../widgets/risk/risk_widgets.dart'; import 'collectibles_page.dart'; import 'kaleidx_scope_page.dart'; import 'map_traverse_page.dart'; @@ -48,10 +47,19 @@ class _HighRiskFeaturePageState extends State @override int? get cooldownLoginDateTime => widget.loginDateTime; - void _openUnlockMusic() { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => UnlockMusicPage( + void _push(Widget page) { + Navigator.of(context).push(MaterialPageRoute(builder: (_) => page)); + } + + /// 中转页只是导航入口,七张卡片的差异只有图标、文案和目标页, + /// 用一张表驱动,避免七份几乎相同的 `Navigator.push`。 + List<({IconData icon, String title, String desc, Widget page})> get _entries { + return [ + ( + icon: Icons.lock_open, + title: AppStrings.unlockFeatureTitle, + desc: AppStrings.unlockFeatureDesc, + page: UnlockMusicPage( userId: widget.userId, cookies: widget.cookies, loginDateTime: widget.loginDateTime, @@ -60,13 +68,11 @@ class _HighRiskFeaturePageState extends State onExitToTitle: widget.onExitToTitle, ), ), - ); - } - - void _openCollectibles() { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => CollectiblesPage( + ( + icon: Icons.card_giftcard, + title: AppStrings.collectiblesFeatureTitle, + desc: AppStrings.collectiblesFeatureDesc, + page: CollectiblesPage( userId: widget.userId, cookies: widget.cookies, loginDateTime: widget.loginDateTime, @@ -75,13 +81,11 @@ class _HighRiskFeaturePageState extends State onExitToTitle: widget.onExitToTitle, ), ), - ); - } - - void _openTravelPartner() { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => TravelPartnerPage( + ( + icon: Icons.workspace_premium_outlined, + title: AppStrings.travelPartnerFeatureTitle, + desc: AppStrings.travelPartnerFeatureDesc, + page: TravelPartnerPage( userId: widget.userId, cookies: widget.cookies, loginDateTime: widget.loginDateTime, @@ -89,28 +93,37 @@ class _HighRiskFeaturePageState extends State onExitToTitle: widget.onExitToTitle, ), ), - ); - } - - void _openNumberPatch(NumberPatchMode mode) { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => NumberPatchPage( + ( + icon: Icons.speed, + title: AppStrings.ratingFeatureTitle, + desc: AppStrings.ratingFeatureDesc, + page: NumberPatchPage( userId: widget.userId, cookies: widget.cookies, loginDateTime: widget.loginDateTime, loginId: widget.loginId, onExitToTitle: widget.onExitToTitle, - mode: mode, + mode: NumberPatchMode.rating, ), ), - ); - } - - void _openMapTraverse() { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => MapTraversePage( + ( + icon: Icons.commute, + title: AppStrings.maiMileFeatureTitle, + desc: AppStrings.maiMileFeatureDesc, + page: NumberPatchPage( + userId: widget.userId, + cookies: widget.cookies, + loginDateTime: widget.loginDateTime, + loginId: widget.loginId, + onExitToTitle: widget.onExitToTitle, + mode: NumberPatchMode.maiMile, + ), + ), + ( + icon: Icons.map_outlined, + title: AppStrings.mapFeatureTitle, + desc: AppStrings.mapFeatureDesc, + page: MapTraversePage( userId: widget.userId, cookies: widget.cookies, loginDateTime: widget.loginDateTime, @@ -118,13 +131,11 @@ class _HighRiskFeaturePageState extends State onExitToTitle: widget.onExitToTitle, ), ), - ); - } - - void _openKaleidxScope() { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => KaleidxScopePage( + ( + icon: Icons.science_outlined, + title: AppStrings.kaleidxFeatureTitle, + desc: AppStrings.kaleidxFeatureDesc, + page: KaleidxScopePage( userId: widget.userId, cookies: widget.cookies, loginDateTime: widget.loginDateTime, @@ -132,167 +143,70 @@ class _HighRiskFeaturePageState extends State onExitToTitle: widget.onExitToTitle, ), ), - ); + ]; } @override Widget build(BuildContext context) { - final theme = Theme.of(context); - if (!TitleServerConfigHolder().isConfigured) { - return Center( - child: Padding( - padding: const EdgeInsets.all(32), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.settings_ethernet, - size: 48, color: theme.colorScheme.primary), - const SizedBox(height: 16), - Text(AppStrings.ticketNotConfigured, - style: theme.textTheme.titleMedium), - ], - ), - ), + return const RiskConfigMissingView( + message: AppStrings.ticketNotConfigured, ); } - return SingleChildScrollView( - padding: const EdgeInsets.all(16), - child: responsiveBody( - context, - child: Column( - children: [ - if (!isLoggedIn) - AppNotice( - AppStrings.musicRiskHubNotLoggedIn, - error: true, - icon: Icons.warning_amber_rounded, - ), - if (isLoggedIn && cooldownRemaining > 0) - AppNotice( - AppStrings.musicRiskHubCooldownNotice(cooldownRemaining), - icon: Icons.timer_outlined, - ), - _buildDescCard(theme), - const SizedBox(height: 12), - _buildNavCard( - theme, - icon: Icons.lock_open, - title: AppStrings.unlockFeatureTitle, - desc: AppStrings.unlockFeatureDesc, - onTap: _openUnlockMusic, - ), - const SizedBox(height: 12), - _buildNavCard( - theme, - icon: Icons.card_giftcard, - title: AppStrings.collectiblesFeatureTitle, - desc: AppStrings.collectiblesFeatureDesc, - onTap: _openCollectibles, - ), - const SizedBox(height: 12), - _buildNavCard( - theme, - icon: Icons.workspace_premium_outlined, - title: AppStrings.travelPartnerFeatureTitle, - desc: AppStrings.travelPartnerFeatureDesc, - onTap: _openTravelPartner, - ), - const SizedBox(height: 12), - _buildNavCard( - theme, - icon: Icons.speed, - title: AppStrings.ratingFeatureTitle, - desc: AppStrings.ratingFeatureDesc, - onTap: () => _openNumberPatch(NumberPatchMode.rating), - ), - const SizedBox(height: 12), - _buildNavCard( - theme, - icon: Icons.commute, - title: AppStrings.maiMileFeatureTitle, - desc: AppStrings.maiMileFeatureDesc, - onTap: () => _openNumberPatch(NumberPatchMode.maiMile), - ), - const SizedBox(height: 12), - _buildNavCard( - theme, - icon: Icons.map_outlined, - title: AppStrings.mapFeatureTitle, - desc: AppStrings.mapFeatureDesc, - onTap: _openMapTraverse, - ), - const SizedBox(height: 12), - _buildNavCard( - theme, - icon: Icons.science_outlined, - title: AppStrings.kaleidxFeatureTitle, - desc: AppStrings.kaleidxFeatureDesc, - onTap: _openKaleidxScope, - ), - ], + final entries = _entries; + return RiskPageBody( + children: [ + RiskStatusNotices( + isLoggedIn: isLoggedIn, + cooldownRemaining: cooldownRemaining, + notLoggedInText: AppStrings.musicRiskHubNotLoggedIn, + cooldownText: AppStrings.musicRiskHubCooldownNotice, ), - ), + const RiskFeatureCard( + icon: Icons.warning_amber_rounded, + title: AppStrings.musicRiskHubTitle, + description: AppStrings.musicRiskHubDesc, + danger: true, + ), + for (var i = 0; i < entries.length; i++) ...[ + const SizedBox(height: 12), + _NavCard( + icon: entries[i].icon, + title: entries[i].title, + desc: entries[i].desc, + onTap: () => _push(entries[i].page), + ), + ], + ], ); } +} - Widget _buildDescCard(ThemeData theme) { +class _NavCard extends StatelessWidget { + final IconData icon; + final String title; + final String desc; + final VoidCallback onTap; + + const _NavCard({ + required this.icon, + required this.title, + required this.desc, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); return Card( elevation: 0, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), side: BorderSide( - color: theme.colorScheme.error.withValues(alpha: 0.4), + color: theme.colorScheme.outline.withValues(alpha: 0.3), ), ), - child: Padding( - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon(Icons.warning_amber_rounded, - size: 18, color: theme.colorScheme.error), - const SizedBox(width: 8), - Expanded( - child: Text( - AppStrings.musicRiskHubTitle, - style: theme.textTheme.labelLarge?.copyWith( - color: theme.colorScheme.error, - fontWeight: FontWeight.w600, - ), - ), - ), - ], - ), - const SizedBox(height: 12), - Text( - AppStrings.musicRiskHubDesc, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - ); - } - - Widget _buildNavCard( - ThemeData theme, { - required IconData icon, - required String title, - required String desc, - required VoidCallback onTap, - }) { - return Card( - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - side: BorderSide(color: theme.colorScheme.outline.withValues(alpha: 0.3)), - ), child: InkWell( borderRadius: BorderRadius.circular(16), onTap: onTap, diff --git a/lib/pages/home/home_tab.dart b/lib/pages/home/home_tab.dart new file mode 100644 index 0000000..35b81a8 --- /dev/null +++ b/lib/pages/home/home_tab.dart @@ -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); +} diff --git a/lib/pages/home/home_tab_view.dart b/lib/pages/home/home_tab_view.dart new file mode 100644 index 0000000..af68838 --- /dev/null +++ b/lib/pages/home/home_tab_view.dart @@ -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), + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index 90a785c..393695a 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -3,19 +3,16 @@ import 'dart:io' show Platform; import 'package:flutter/material.dart'; import 'package:flutter/services.dart' show SystemNavigator; -import '../config/responsive.dart'; -import '../config/strings.dart'; import '../config/title_server_config.dart'; import '../models/session_model.dart'; import '../models/user_data.dart'; import '../models/user_preview.dart'; import '../services/title_api_service.dart'; -import '../widgets/account_backup_card.dart'; -import '../widgets/app_notice.dart'; -import '../widgets/connection_share_card.dart'; import 'about_page.dart'; import 'best50_page.dart'; import 'high_risk_feature_page.dart'; +import 'home/home_tab.dart'; +import 'home/home_tab_view.dart'; import 'settings_page.dart'; import 'ticket_page.dart'; @@ -44,24 +41,21 @@ class HomePage extends StatefulWidget { } class _HomePageState extends State { - int _currentTab = 0; + HomeTab _currentTab = HomeTab.home; + + /// 只有用户真正打开过的页签才构建。否则 IndexedStack 会在启动时把 + /// 票据 / B50 / 风险 hub 全部建出来,各自立刻发一轮请求。 + final Set _visitedTabs = {HomeTab.home}; + UserPreviewDataBean? _preview; UserDataBean? _userData; + String? _rawResponse; String? _error; bool _loading = true; bool _loggingOut = false; SessionModel get _session => SessionModel.instance; - static const _tabTitles = [ - AppStrings.tabHome, - AppStrings.tabTickets, - AppStrings.tabBest50, - AppStrings.tabRisk, - AppStrings.tabSettings, - AppStrings.tabAbout, - ]; - @override void initState() { super.initState(); @@ -69,6 +63,21 @@ class _HomePageState extends State { } Future _bootstrap() async { + final restored = widget.restored; + final seed = restored?.userData; + if (restored != null && seed != null) { + // 恢复来的 JSESSIONID 已在 SessionModel._resumeCookieSession 里验证过,并 + // 顺带拉回数据与概要,这里直接展示,不再发 GetUserPreviewApi / UserLoginApi。 + // 这条路径不依赖 Title Server 配置,所以要在配置检查之前处理。 + setState(() { + _preview = restored.preview; + _userData = seed; + _rawResponse = restored.rawPreviewResponse; + _loading = false; + }); + return; + } + if (!TitleServerConfigHolder().isConfigured) { setState(() { _loading = false; @@ -84,22 +93,9 @@ class _HomePageState extends State { _userData = null; }); - final restored = widget.restored; - final seed = restored?.userData; - if (restored != null && seed != null) { - // 恢复来的 JSESSIONID 已在 SessionModel._resumeCookieSession 里验证过,并 - // 顺带拉回数据与概要,这里直接展示,不再发 GetUserPreviewApi / UserLoginApi。 - setState(() { - _preview = restored.preview; - _userData = seed; - _loading = false; - }); - return; - } - if (restored == null) _session.setGameLogin(null); - final service = TitleApiService.fromHolder(cookies: _session.cookies)!; + final service = _session.createTitleService()!; try { final preview = await service.getUserPreview( @@ -107,8 +103,10 @@ class _HomePageState extends State { token: widget.token, ); if (!mounted) return; - _session.updateCookies(service.cookies); - setState(() => _preview = preview); + setState(() { + _preview = preview; + _rawResponse = service.lastRawResponse; + }); // isLogin == true => 已有人登录, 不再走完整登录, 直接展示 preview 概要 // forcePreviewApi => 跳过 UserLoginApi, 仅展示 preview + Login 按钮 @@ -124,28 +122,15 @@ class _HomePageState extends State { if (!mounted) return; } - final userDataService = TitleApiService.fromHolder( - cookies: _session.cookies, - )!; + final userDataService = _session.createTitleService()!; final userData = await userDataService.getUserDataTyped(widget.userId); if (!mounted) return; - _session.updateCookies(userDataService.cookies); setState(() { _userData = userData; _loading = false; }); - } on TitleApiException catch (e) { - if (!mounted) return; - setState(() { - _error = e.message; - _loading = false; - }); } catch (e) { - if (!mounted) return; - setState(() { - _error = e.toString(); - _loading = false; - }); + _fail(e); } } @@ -160,29 +145,26 @@ class _HomePageState extends State { await _session.loginGame(userId: widget.userId, token: widget.token); if (!mounted) return; - final service = TitleApiService.fromHolder(cookies: _session.cookies)!; + final service = _session.createTitleService()!; final userData = await service.getUserDataTyped(widget.userId); if (!mounted) return; - _session.updateCookies(service.cookies); setState(() { _userData = userData; _loading = false; }); - } on TitleApiException catch (e) { - if (!mounted) return; - setState(() { - _error = e.message; - _loading = false; - }); } catch (e) { - if (!mounted) return; - setState(() { - _error = e.toString(); - _loading = false; - }); + _fail(e); } } + void _fail(Object error) { + if (!mounted) return; + setState(() { + _error = describeApiError(error); + _loading = false; + }); + } + Future _logoutSession() async { await _session.logoutGame(userId: widget.userId); } @@ -215,10 +197,16 @@ class _HomePageState extends State { SystemNavigator.pop(); } + /// 切换页签;首次访问时才把对应页面加入构建集合。 + void _selectTab(HomeTab tab) { + setState(() { + _currentTab = tab; + _visitedTabs.add(tab); + }); + } + @override Widget build(BuildContext context) { - final theme = Theme.of(context); - return ListenableBuilder( listenable: _session, builder: (context, _) { @@ -243,78 +231,28 @@ class _HomePageState extends State { : const Icon(Icons.arrow_back), onPressed: _loggingOut ? null : _performLogoutAndExit, ), - title: Text(_tabTitles[_currentTab]), + title: Text(_currentTab.title), ), body: IndexedStack( - index: _currentTab, + index: _currentTab.index, children: [ - _buildBody(theme), - TicketPage( - userId: widget.userId, - cookies: _session.cookies, - loginDateTime: _session.gameLogin?.loginDateTime, - playerRating: - _userData?.playerRating ?? _preview?.playerRating ?? 0, - onLogoutRequested: _session.gameLogin != null - ? _logoutSession - : null, - ), - Best50Page( - userId: widget.userId, - cookies: _session.cookies, - userName: _userData?.userName ?? _preview?.userName ?? '', - iconId: _userData?.iconId ?? _preview?.iconId ?? 0, - playerRating: - _userData?.playerRating ?? _preview?.playerRating ?? 0, - ), - HighRiskFeaturePage( - userId: widget.userId, - cookies: _session.cookies, - loginDateTime: _session.gameLogin?.loginDateTime, - loginId: _session.gameLogin?.loginId, - lastLoginDate: _session.gameLogin?.lastLoginDate, - onExitToTitle: _session.gameLogin != null - ? _performLogoutAndExit - : null, - ), - SettingsPage(showAppBar: false), - const AboutPage(), + for (final tab in HomeTab.values) + if (_visitedTabs.contains(tab)) + _buildTab(tab) + else + const SizedBox.shrink(), ], ), bottomNavigationBar: NavigationBar( - selectedIndex: _currentTab, - onDestinationSelected: (i) => setState(() => _currentTab = i), - destinations: const [ - NavigationDestination( - icon: Icon(Icons.home_outlined), - selectedIcon: Icon(Icons.home), - label: AppStrings.tabHome, - ), - NavigationDestination( - icon: Icon(Icons.confirmation_number_outlined), - selectedIcon: Icon(Icons.confirmation_number), - label: AppStrings.tabTickets, - ), - NavigationDestination( - icon: Icon(Icons.bar_chart_outlined), - selectedIcon: Icon(Icons.bar_chart), - label: AppStrings.tabBest50, - ), - NavigationDestination( - icon: Icon(Icons.warning_outlined), - selectedIcon: Icon(Icons.inventory_2), - label: AppStrings.tabRisk, - ), - NavigationDestination( - icon: Icon(Icons.settings_outlined), - selectedIcon: Icon(Icons.settings), - label: AppStrings.tabSettings, - ), - NavigationDestination( - icon: Icon(Icons.info_outlined), - selectedIcon: Icon(Icons.info), - label: AppStrings.tabAbout, - ), + selectedIndex: _currentTab.index, + onDestinationSelected: (i) => _selectTab(HomeTab.values[i]), + destinations: [ + for (final tab in HomeTab.values) + NavigationDestination( + icon: Icon(tab.icon), + selectedIcon: Icon(tab.selectedIcon), + label: tab.title, + ), ], ), ), @@ -323,928 +261,61 @@ class _HomePageState extends State { ); } - Widget _buildBody(ThemeData theme) { - if (!TitleServerConfigHolder().isConfigured) { - 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: () => setState(() => _currentTab = 4), - icon: const Icon(Icons.settings, size: 20), - label: const Text(AppStrings.openSettings), - ), - ], - ), - ), - ); + Widget _buildTab(HomeTab tab) { + switch (tab) { + case HomeTab.home: + return HomeTabView( + userId: widget.userId, + token: widget.token, + cookies: _session.cookies, + preview: _preview, + userData: _userData, + loading: _loading, + error: _error, + isGameLoggedIn: _session.gameLogin != null, + forcePreviewApi: widget.forcePreviewApi, + showRestoredNotice: + widget.restored != null && _session.gameLogin == null, + rawJson: _rawResponse, + onRetry: _bootstrap, + onOpenSettings: () => _selectTab(HomeTab.settings), + onLogin: _performLogin, + ); + case HomeTab.tickets: + return TicketPage( + userId: widget.userId, + cookies: _session.cookies, + loginDateTime: _session.gameLogin?.loginDateTime, + playerRating: + _userData?.playerRating ?? _preview?.playerRating ?? 0, + onLogoutRequested: _session.gameLogin != null + ? _logoutSession + : null, + ); + case HomeTab.best50: + return Best50Page( + userId: widget.userId, + cookies: _session.cookies, + userName: _userData?.userName ?? _preview?.userName ?? '', + iconId: _userData?.iconId ?? _preview?.iconId ?? 0, + playerRating: + _userData?.playerRating ?? _preview?.playerRating ?? 0, + ); + case HomeTab.risk: + return HighRiskFeaturePage( + userId: widget.userId, + cookies: _session.cookies, + loginDateTime: _session.gameLogin?.loginDateTime, + loginId: _session.gameLogin?.loginId, + lastLoginDate: _session.gameLogin?.lastLoginDate, + onExitToTitle: _session.gameLogin != null + ? _performLogoutAndExit + : null, + ); + case HomeTab.settings: + return SettingsPage(showAppBar: false); + case HomeTab.about: + return const AboutPage(); } - - if (_loading) { - return const Center(child: CircularProgressIndicator()); - } - - if (_error != null) { - 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: _bootstrap, - icon: const Icon(Icons.refresh, size: 20), - label: const Text(AppStrings.retry), - ), - ], - ), - ), - ); - } - - final userData = _userData; - if (userData != null) { - return _buildFullData(theme, _preview, userData); - } - - final preview = _preview; - if (preview == null) { - return const SizedBox.shrink(); - } - - if (preview.isLogin || - (widget.forcePreviewApi && _session.gameLogin == null)) { - return _buildPreviewOnly( - theme, - preview, - showLoginButton: widget.forcePreviewApi && _session.gameLogin == null, - onLoginPressed: _performLogin, - ); - } - - return const SizedBox.shrink(); - } - - Widget _buildPreviewOnly( - ThemeData theme, - UserPreviewDataBean preview, { - bool showLoginButton = false, - VoidCallback? onLoginPressed, - }) { - return SingleChildScrollView( - padding: const EdgeInsets.all(16), - child: responsiveBody( - context, - child: Column( - children: [ - if (preview.errorId != 0) - _ErrorIdBanner(theme: theme, 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: _loading ? null : onLoginPressed, - icon: _loading - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.login, size: 20), - label: Text( - _loading ? AppStrings.loggingIn : AppStrings.login, - ), - style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 14), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - ), - ), - ], - const SizedBox(height: 12), - _ProfileFromPreviewCard(theme: theme, preview: preview), - const SizedBox(height: 12), - ...responsiveGrid( - context: context, - children: [ - _PreviewGameInfoCard(theme: theme, preview: preview), - _PreviewStatusCard(theme: theme, preview: preview), - ], - ), - const SizedBox(height: 12), - ConnectionShareCard(userId: widget.userId, token: widget.token), - if (TitleApiService.lastRawResponse != null) ...[ - const SizedBox(height: 12), - _DebugRawJsonCard( - theme: theme, - rawJson: TitleApiService.lastRawResponse!, - ), - ], - ], - ), - ), - ); - } - - Widget _buildFullData( - ThemeData theme, - UserPreviewDataBean? preview, - UserDataBean data, - ) { - return SingleChildScrollView( - padding: const EdgeInsets.all(16), - child: responsiveBody( - context, - child: Column( - children: [ - if (widget.restored != null && _session.gameLogin == null) - AppNotice( - AppStrings.sessionRestoredNotice, - icon: Icons.science_outlined, - ), - _ProfileFromUserDataCard(theme: theme, data: data), - const SizedBox(height: 12), - ...responsiveGrid( - context: context, - children: [ - _RatingBreakdownCard(theme: theme, data: data), - _FirstPlayCard(theme: theme, data: data), - _GameInfoFromUserDataCard(theme: theme, data: data), - _PlayStatsCard(theme: theme, data: data), - _StatusFromUserDataCard( - theme: theme, - preview: preview, - data: data, - ), - _UserDataDetailCard(theme: theme, data: data), - ], - ), - const SizedBox(height: 12), - ConnectionShareCard(userId: widget.userId, token: widget.token), - const SizedBox(height: 12), - AccountBackupCard(userId: widget.userId, cookies: _session.cookies), - if (TitleApiService.lastRawResponse != null) ...[ - const SizedBox(height: 12), - _DebugRawJsonCard( - theme: theme, - rawJson: TitleApiService.lastRawResponse!, - ), - ], - ], - ), - ), - ); - } -} - -// ============================================================================= -// Preview-only (isLogin == true) widgets -// ============================================================================= - -class _ProfileFromPreviewCard extends StatelessWidget { - final ThemeData theme; - final UserPreviewDataBean preview; - - const _ProfileFromPreviewCard({required this.theme, required this.preview}); - - @override - Widget build(BuildContext context) { - 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: Row( - children: [ - _Avatar(theme: theme, iconId: preview.iconId), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - preview.userName.isEmpty - ? AppStrings.unknownUser - : preview.userName, - style: theme.textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 4), - Text( - AppStrings.rating(preview.playerRating), - style: theme.textTheme.bodyLarge?.copyWith( - color: theme.colorScheme.primary, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - ], - ), - ), - ); - } -} - -class _PreviewGameInfoCard extends StatelessWidget { - final ThemeData theme; - final UserPreviewDataBean preview; - - const _PreviewGameInfoCard({required this.theme, required this.preview}); - - @override - Widget build(BuildContext context) { - return _SectionCard( - theme: theme, - 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 ThemeData theme; - final UserPreviewDataBean preview; - - const _PreviewStatusCard({required this.theme, required this.preview}); - - @override - Widget build(BuildContext context) { - return Card( - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - side: BorderSide( - color: theme.colorScheme.outline.withValues(alpha: 0.3), - ), - ), - child: Padding( - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - AppStrings.status, - style: theme.textTheme.labelLarge?.copyWith( - color: theme.colorScheme.primary, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 14), - _statusChip(theme, AppStrings.netMember, preview.isNetMember), - _statusChip(theme, AppStrings.inherit, preview.isInherit), - _statusChip( - theme, - AppStrings.banState, - preview.banState == 0, - trueText: AppStrings.clean, - falseText: '已封禁 (${preview.banState})', - ), - ], - ), - ), - ); - } -} - -// ============================================================================= -// Full UserData widgets -// ============================================================================= - -class _ProfileFromUserDataCard extends StatelessWidget { - final ThemeData theme; - final UserDataBean data; - - const _ProfileFromUserDataCard({required this.theme, required this.data}); - - @override - Widget build(BuildContext context) { - 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: Row( - children: [ - _Avatar(theme: theme, iconId: data.iconId), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - data.userName.isEmpty - ? AppStrings.unknownUser - : data.userName, - style: theme.textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 4), - Text( - AppStrings.rating(data.playerRating), - style: theme.textTheme.bodyLarge?.copyWith( - color: theme.colorScheme.primary, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 2), - Text( - '${AppStrings.highestRating}: ${data.highestRating}', - style: theme.textTheme.labelSmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - ], - ), - ), - ); - } -} - -class _RatingBreakdownCard extends StatelessWidget { - final ThemeData theme; - final UserDataBean data; - - const _RatingBreakdownCard({required this.theme, required this.data}); - - @override - Widget build(BuildContext context) { - return _SectionCard( - theme: theme, - 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 ThemeData theme; - final UserDataBean data; - - const _FirstPlayCard({required this.theme, required this.data}); - - @override - Widget build(BuildContext context) { - return _SectionCard( - theme: theme, - 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 ThemeData theme; - final UserDataBean data; - - const _GameInfoFromUserDataCard({required this.theme, required this.data}); - - @override - Widget build(BuildContext context) { - return _SectionCard( - theme: theme, - 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 ThemeData theme; - final UserDataBean data; - - const _PlayStatsCard({required this.theme, required this.data}); - - @override - Widget build(BuildContext context) { - return _SectionCard( - theme: theme, - 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 { - final ThemeData theme; - - /// 用连接信息(仅 JSESSIONID)恢复的会话没有走 GetUserPreviewApi,可为空。 - final UserPreviewDataBean? preview; - final UserDataBean data; - - const _StatusFromUserDataCard({ - required this.theme, - required this.preview, - required this.data, - }); - - @override - Widget build(BuildContext context) { - final p = preview; - - return Card( - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - side: BorderSide( - color: theme.colorScheme.outline.withValues(alpha: 0.3), - ), - ), - child: Padding( - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - AppStrings.status, - style: theme.textTheme.labelLarge?.copyWith( - color: theme.colorScheme.primary, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 14), - _statusChip(theme, AppStrings.netMember, data.isNetMember), - if (p != null) _statusChip(theme, AppStrings.inherit, p.isInherit), - _statusChip( - theme, - AppStrings.banState, - data.banState == 0, - trueText: AppStrings.clean, - falseText: '已封禁 (${data.banState})', - ), - _statusChip( - theme, - AppStrings.dailyBonus, - data.dailyBonusDate.isNotEmpty && - !data.dailyBonusDate.startsWith('1970'), - trueText: data.dailyBonusDate, - falseText: AppStrings.notClaimed, - ), - ], - ), - ), - ); - } -} - -class _UserDataDetailCard extends StatefulWidget { - final ThemeData theme; - final UserDataBean data; - - const _UserDataDetailCard({required this.theme, required this.data}); - - @override - State<_UserDataDetailCard> createState() => _UserDataDetailCardState(); -} - -class _UserDataDetailCardState extends State<_UserDataDetailCard> { - bool _expanded = false; - - @override - Widget build(BuildContext context) { - final theme = widget.theme; - final data = widget.data; - - return Card( - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - side: BorderSide( - color: theme.colorScheme.outline.withValues(alpha: 0.3), - ), - ), - child: InkWell( - borderRadius: BorderRadius.circular(16), - onTap: () => setState(() => _expanded = !_expanded), - child: Padding( - padding: const EdgeInsets.all(20), - 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)), - ], - ), - ); - } -} - -// ============================================================================= -// Shared helpers -// ============================================================================= - -class _SectionCard extends StatelessWidget { - final ThemeData theme; - final String title; - final List<(String, String)> rows; - - const _SectionCard({ - required this.theme, - required this.title, - required this.rows, - }); - - @override - Widget build(BuildContext context) { - return Card( - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - side: BorderSide( - color: theme.colorScheme.outline.withValues(alpha: 0.3), - ), - ), - child: Padding( - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: theme.textTheme.labelLarge?.copyWith( - color: theme.colorScheme.primary, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 14), - for (final (label, value) in rows) _infoRow(theme, label, value), - ], - ), - ), - ); - } - - Widget _infoRow(ThemeData theme, String label, String value) { - 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, - ), - ), - ], - ), - ); - } -} - -class _Avatar extends StatelessWidget { - final ThemeData theme; - final int iconId; - - const _Avatar({required this.theme, required this.iconId}); - - @override - Widget build(BuildContext 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, - ); - } -} - -Widget _statusChip( - ThemeData theme, - String label, - dynamic value, { - String trueText = 'Yes', - String falseText = 'No', -}) { - String text; - Color color; - if (value is bool) { - text = value ? trueText : falseText; - color = value ? Colors.green : Colors.grey; - } else { - text = '$value'; - 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), - ), - ), - ], - ), - ); -} - -class _DebugRawJsonCard extends StatefulWidget { - final ThemeData theme; - final String rawJson; - - const _DebugRawJsonCard({required this.theme, required this.rawJson}); - - @override - State<_DebugRawJsonCard> createState() => _DebugRawJsonCardState(); -} - -class _DebugRawJsonCardState extends State<_DebugRawJsonCard> { - bool _expanded = false; - - @override - Widget build(BuildContext context) { - final theme = widget.theme; - - return Card( - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - side: BorderSide(color: Colors.orange.withValues(alpha: 0.5)), - ), - child: InkWell( - borderRadius: BorderRadius.circular(16), - onTap: () => setState(() => _expanded = !_expanded), - child: Padding( - padding: const EdgeInsets.all(20), - 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 ThemeData theme; - final int errorId; - - const _ErrorIdBanner({required this.theme, required this.errorId}); - - @override - Widget build(BuildContext 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, - ), - ), - ), - ], - ), - ); } } diff --git a/lib/pages/kaleidx_scope_page.dart b/lib/pages/kaleidx_scope_page.dart index 2f69ecb..c47ac7b 100644 --- a/lib/pages/kaleidx_scope_page.dart +++ b/lib/pages/kaleidx_scope_page.dart @@ -1,15 +1,16 @@ import 'package:flutter/material.dart'; -import '../config/responsive.dart'; import '../config/strings.dart'; -import '../config/title_server_config.dart'; +import '../models/session_model.dart'; import '../models/user_kaleidx_scope.dart'; import '../services/title_api_service.dart'; import '../services/user_all_payload_builder.dart'; import '../widgets/app_card.dart'; import '../widgets/app_notice.dart'; import '../widgets/cooldown_mixin.dart'; +import '../widgets/risk/risk_run_mixin.dart'; +import '../widgets/risk/risk_widgets.dart'; import '../widgets/step_progress.dart'; /// 万花筒专区:发现新的宿命之门、获取门的钥匙、设置门的通关状态。 @@ -41,7 +42,7 @@ class KaleidxScopePage extends StatefulWidget { enum _ClearChoice { keep, cleared, uncleared } class _KaleidxScopePageState extends State - with CooldownMixin { + with CooldownMixin, RiskRunMixin { final _gateIdController = TextEditingController(); final List _pending = []; @@ -52,12 +53,6 @@ class _KaleidxScopePageState extends State Map> _userAllData = const {}; List _serverScopes = const []; - bool _loading = false; - bool _running = false; - bool _autoLogout = true; - RiskStep _step = RiskStep.idle; - String _stepMessage = ''; - @override int? get cooldownLoginDateTime => widget.loginDateTime; @@ -120,11 +115,13 @@ class _KaleidxScopePageState extends State } Future _fetchData() async { - if (!TitleServerConfigHolder().isConfigured) return; - setState(() => _loading = true); + final service = SessionModel.instance.createTitleService( + cookies: widget.cookies, + ); + if (service == null) return; + setRiskFetching(true); try { - final service = TitleApiService.fromHolder(cookies: widget.cookies)!; final results = await Future.wait([ service.fetchUserAllData(widget.userId), service.getUserKaleidxScopes(widget.userId), @@ -133,28 +130,25 @@ class _KaleidxScopePageState extends State setState(() { _userAllData = results[0] as Map>; _serverScopes = results[1] as List; - _loading = false; + riskFetching = false; }); } catch (e) { if (!mounted) return; - setState(() => _loading = false); - _snack('${AppStrings.loadFailed}: $e'); + setState(() => riskFetching = false); + _snack('${AppStrings.loadFailed}: ${describeApiError(e)}'); } } Future _run() async { - if (!TitleServerConfigHolder().isConfigured) { - _snack(AppStrings.ticketNotConfigured); - return; - } - final loginDateTime = widget.loginDateTime; - final loginId = widget.loginId; - if (loginDateTime == null || loginId == null) { - _snack(AppStrings.kaleidxNotLoggedIn); - return; - } - if (cooldownRemaining > 0) { - _snack(AppStrings.kaleidxCooldownNotice(cooldownRemaining)); + final guard = riskPreconditionError( + loginDateTime: widget.loginDateTime, + loginId: widget.loginId, + cooldownRemaining: cooldownRemaining, + notLoggedInMessage: AppStrings.kaleidxNotLoggedIn, + cooldownNotice: AppStrings.kaleidxCooldownNotice, + ); + if (guard != null) { + _snack(guard); return; } if (_pending.isEmpty) { @@ -166,15 +160,19 @@ class _KaleidxScopePageState extends State return; } - setState(() => _running = true); + final loginDateTime = widget.loginDateTime!; + final loginId = widget.loginId!; + setRiskRunning(true); try { - final service = TitleApiService.fromHolder(cookies: widget.cookies)!; + final service = SessionModel.instance.createTitleService( + cookies: widget.cookies, + )!; final builder = UserAllPayloadBuilder(service.config); var data = _userAllData; if (data.isEmpty || _serverScopes.isEmpty) { - _updateStep(RiskStep.fetchData); + updateRiskStep(RiskStep.fetchData); final results = await Future.wait([ service.fetchUserAllData(widget.userId), service.getUserKaleidxScopes(widget.userId), @@ -188,7 +186,7 @@ class _KaleidxScopePageState extends State } } - _updateStep(RiskStep.upload); + updateRiskStep(RiskStep.upload); final packet = builder.build( userId: widget.userId, loginId: loginId, @@ -209,20 +207,12 @@ class _KaleidxScopePageState extends State ); await service.upsertUserAll(packet, widget.userId); - _updateStep(RiskStep.complete, await _verify(service)); - - if (_autoLogout && widget.onExitToTitle != null) { - await Future.delayed(const Duration(seconds: 2)); - if (!mounted) return; - _updateStep(RiskStep.logout, AppStrings.exitingToTitle); - await widget.onExitToTitle!(); - } - } on TitleApiException catch (e) { - _updateStep(RiskStep.failed, e.message); + updateRiskStep(RiskStep.complete, await _verify(service)); + await autoLogoutAndExit(widget.onExitToTitle); } catch (e) { - _updateStep(RiskStep.failed, '$e'); + reportRiskError(e); } finally { - if (mounted) setState(() => _running = false); + setRiskRunning(false); } } @@ -232,7 +222,7 @@ class _KaleidxScopePageState extends State try { rows = await service.getUserKaleidxScopes(widget.userId); } catch (e) { - return AppStrings.kaleidxVerifyFailed('$e'); + return AppStrings.kaleidxVerifyFailed(describeApiError(e)); } if (mounted) setState(() => _serverScopes = rows); @@ -246,106 +236,73 @@ class _KaleidxScopePageState extends State return AppStrings.kaleidxVerified(saved.length); } - void _updateStep(RiskStep step, [String? message]) { - if (!mounted) return; - setState(() { - _step = step; - _stepMessage = message ?? ''; - }); - } - @override Widget build(BuildContext context) { final theme = Theme.of(context); return Scaffold( appBar: AppBar(title: const Text(AppStrings.kaleidxFeatureTitle)), - body: SingleChildScrollView( - padding: const EdgeInsets.all(16), - child: responsiveBody( - context, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (!isLoggedIn) - const AppNotice(AppStrings.kaleidxNotLoggedIn, error: true), - if (isLoggedIn && cooldownRemaining > 0) - AppNotice( - AppStrings.kaleidxCooldownNotice(cooldownRemaining), - ), - AppCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SectionTitle( - icon: Icons.science_outlined, - title: AppStrings.kaleidxFeatureTitle, - ), - const SizedBox(height: 12), - Text( - AppStrings.kaleidxFeatureDesc, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - height: 1.5, - ), - ), - ], - ), - ), - const SizedBox(height: 12), - _inputCard(theme), - const SizedBox(height: 12), - _fetchCard(theme), - const SizedBox(height: 12), - AutoLogoutToggle( - value: _autoLogout, - enabled: widget.onExitToTitle != null && !_running, - onChanged: (v) => setState(() => _autoLogout = v), - ), - const SizedBox(height: 12), - _runButton(theme), - if (_step != RiskStep.idle) ...[ - const SizedBox(height: 16), - StepProgressCard( - step: _step, - message: _stepMessage, - steps: [ - (RiskStep.fetchData, AppStrings.kaleidxStepFetch), - (RiskStep.upload, AppStrings.kaleidxStepUpload), - if (_autoLogout) (RiskStep.logout, AppStrings.stepLogout), - ], - ), - ], - ], - ), - ), - ), - ); - } - - Widget _checkRow({ - required String label, - required bool value, - required bool enabled, - required ValueChanged onChanged, - }) { - return InkWell( - borderRadius: BorderRadius.circular(10), - onTap: enabled ? () => onChanged(!value) : null, - child: Row( + body: RiskPageBody( children: [ - Checkbox( - value: value, - onChanged: enabled ? (v) => onChanged(v ?? false) : null, + RiskStatusNotices( + isLoggedIn: isLoggedIn, + cooldownRemaining: cooldownRemaining, + notLoggedInText: AppStrings.kaleidxNotLoggedIn, + cooldownText: AppStrings.kaleidxCooldownNotice, ), - Expanded(child: Text(label)), + RiskFeatureCard( + icon: Icons.science_outlined, + title: AppStrings.kaleidxFeatureTitle, + description: AppStrings.kaleidxFeatureDesc, + ), + const SizedBox(height: 12), + _inputCard(theme), + const SizedBox(height: 12), + RiskFetchCard( + hasData: _userAllData.isNotEmpty, + statusText: _userAllData.isNotEmpty + ? '${AppStrings.kaleidxFetched} (${_serverScopes.length})' + : AppStrings.kaleidxNoData, + loadingLabel: AppStrings.travelPartnerFetching, + actionLabel: AppStrings.kaleidxFetchData, + loading: riskFetching, + enabled: !riskRunning, + onFetch: _fetchData, + ), + const SizedBox(height: 12), + AutoLogoutToggle( + value: riskAutoLogout, + enabled: widget.onExitToTitle != null && !riskRunning, + onChanged: setRiskAutoLogout, + ), + const SizedBox(height: 12), + RiskRunButton( + running: riskRunning, + enabled: isLoggedIn, + icon: Icons.lock_open_outlined, + label: AppStrings.kaleidxRun, + runningLabel: AppStrings.kaleidxRunning, + onPressed: _run, + ), + if (riskStep != RiskStep.idle) ...[ + const SizedBox(height: 16), + StepProgressCard( + step: riskStep, + message: riskStepMessage, + steps: [ + (RiskStep.fetchData, AppStrings.kaleidxStepFetch), + (RiskStep.upload, AppStrings.kaleidxStepUpload), + if (riskAutoLogout) (RiskStep.logout, AppStrings.stepLogout), + ], + ), + ], ], ), ); } Widget _inputCard(ThemeData theme) { - final enabled = !_running; + final enabled = !riskRunning; return AppCard( child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -376,13 +333,13 @@ class _KaleidxScopePageState extends State ), ), const SizedBox(height: 4), - _checkRow( + RiskCheckRow( label: AppStrings.kaleidxActionDiscover, value: _discover, enabled: enabled, onChanged: (v) => setState(() => _discover = v), ), - _checkRow( + RiskCheckRow( label: AppStrings.kaleidxActionKey, value: _giveKey, enabled: enabled, @@ -466,82 +423,4 @@ class _KaleidxScopePageState extends State 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), - ), - ), - ), - ); - } } diff --git a/lib/pages/login_page.dart b/lib/pages/login_page.dart index 599bc51..2ec9da0 100644 --- a/lib/pages/login_page.dart +++ b/lib/pages/login_page.dart @@ -6,6 +6,7 @@ import '../models/session_model.dart'; import '../models/session_share.dart'; import '../services/file_picker_service.dart'; import '../services/qr_service.dart'; +import '../services/title_api_service.dart'; import '../widgets/app_notice.dart'; import 'home_page.dart'; import 'settings_page.dart'; @@ -22,7 +23,6 @@ class LoginPage extends StatefulWidget { class _LoginPageState extends State { final _qrController = TextEditingController(); - String _qrCode = ''; String _rawInput = ''; SessionShare? _share; bool _loading = false; @@ -38,7 +38,6 @@ class _LoginPageState extends State { final text = value.trim(); setState(() { _rawInput = text; - _qrCode = SessionModel.extractQRCode(text); _share = SessionShare.tryDecode(text); }); } @@ -51,12 +50,9 @@ class _LoginPageState extends State { final result = await decodeQRFromBytes(bytes); if (result != null && result.isNotEmpty) { + if (!mounted) return; _qrController.text = result; - setState(() { - _rawInput = result.trim(); - _qrCode = SessionModel.extractQRCode(result); - _share = SessionShare.tryDecode(result); - }); + _onQRContentChanged(result); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text(AppStrings.qrScanSuccess)), @@ -79,18 +75,11 @@ class _LoginPageState extends State { } Future _onLogin() async { - if (_qrCode.isEmpty) return; - - // 令牌是粘贴内容的后 64 位,前缀可能在截取之后就没了,所以要拿原文判断; - // 认不出来时必须给提示,否则点了按钮一声不响,像是坏了。 - if (!_rawInput.contains('SGWCMAID')) { - context.showSnack(AppStrings.qrTokenUnrecognized); - return; - } + if (_rawInput.isEmpty) return; setState(() => _loading = true); try { - final result = await SessionModel.instance.loginWithQr(_qrCode); + final result = await SessionModel.instance.loginWithQr(_rawInput); if (!mounted) return; @@ -105,18 +94,22 @@ class _LoginPageState extends State { ), ); } else { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - '${AppStrings.loginFailed} (errorID: ${result.errorId})', - ), - ), + context.showSnack( + '${AppStrings.loginFailed}:' + '${result.message ?? 'errorID=${result.errorId}'}', ); } + } on FormatException catch (e) { + // 令牌格式不对 / 没配 Keychip:登录请求根本没发出去,要说清楚是哪一种。 + if (mounted) { + context.showSnack('${AppStrings.qrTokenUnrecognized}:${e.message}'); + } } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('${AppStrings.requestFailed}: $e')), + SnackBar( + content: Text('${AppStrings.requestFailed}: ${describeApiError(e)}'), + ), ); } } finally { @@ -155,7 +148,9 @@ class _LoginPageState extends State { ); } catch (e) { if (mounted) { - context.showSnack('${AppStrings.sessionRestoreFailed}: $e'); + context.showSnack( + '${AppStrings.sessionRestoreFailed}: ${describeApiError(e)}', + ); } } finally { if (mounted) setState(() => _loading = false); @@ -285,7 +280,7 @@ class _LoginPageState extends State { child: FilledButton.icon( onPressed: isRestore ? (_loading ? null : _onRestoreSession) - : (_qrCode.isNotEmpty && !_loading) + : (_rawInput.isNotEmpty && !_loading) ? _onLogin : null, icon: _loading diff --git a/lib/pages/map_traverse_page.dart b/lib/pages/map_traverse_page.dart index db5599d..5906525 100644 --- a/lib/pages/map_traverse_page.dart +++ b/lib/pages/map_traverse_page.dart @@ -1,14 +1,16 @@ import 'package:flutter/material.dart'; -import '../config/responsive.dart'; import '../config/strings.dart'; -import '../config/title_server_config.dart'; +import '../core/json_value.dart'; +import '../models/session_model.dart'; import '../services/title_api_service.dart'; import '../services/user_all_payload_builder.dart'; import '../widgets/app_card.dart'; import '../widgets/app_notice.dart'; import '../widgets/cooldown_mixin.dart'; +import '../widgets/risk/risk_run_mixin.dart'; +import '../widgets/risk/risk_widgets.dart'; import '../widgets/step_progress.dart'; /// 一键跑图:把选中的区域标记为已完成。 @@ -43,7 +45,7 @@ class MapTraversePage extends StatefulWidget { } class _MapTraversePageState extends State - with CooldownMixin { + with CooldownMixin, RiskRunMixin { final _mapIdController = TextEditingController(); final List _pending = []; @@ -51,12 +53,6 @@ class _MapTraversePageState extends State Set? _serverMapIds; List> _serverMaps = const []; - bool _loading = false; - bool _running = false; - bool _autoLogout = true; - RiskStep _step = RiskStep.idle; - String _stepMessage = ''; - @override int? get cooldownLoginDateTime => widget.loginDateTime; @@ -87,17 +83,19 @@ class _MapTraversePageState extends State void _applyServerMaps(List> rows) { _serverMaps = rows; _serverMapIds = rows - .map((e) => (e['mapId'] as num?)?.toInt() ?? 0) + .map((e) => jsonInt(e['mapId'])) .where((e) => e != 0) .toSet(); } Future _fetchData() async { - if (!TitleServerConfigHolder().isConfigured) return; - setState(() => _loading = true); + final service = SessionModel.instance.createTitleService( + cookies: widget.cookies, + ); + if (service == null) return; + setRiskFetching(true); try { - final service = TitleApiService.fromHolder(cookies: widget.cookies)!; final results = await Future.wait([ service.fetchUserAllData(widget.userId), service.getUserMaps(widget.userId), @@ -106,28 +104,25 @@ class _MapTraversePageState extends State setState(() { _userAllData = results[0] as Map>; _applyServerMaps(results[1] as List>); - _loading = false; + riskFetching = false; }); } catch (e) { if (!mounted) return; - setState(() => _loading = false); - _snack('${AppStrings.loadFailed}: $e'); + setState(() => riskFetching = false); + _snack('${AppStrings.loadFailed}: ${describeApiError(e)}'); } } Future _run() async { - if (!TitleServerConfigHolder().isConfigured) { - _snack(AppStrings.ticketNotConfigured); - return; - } - final loginDateTime = widget.loginDateTime; - final loginId = widget.loginId; - if (loginDateTime == null || loginId == null) { - _snack(AppStrings.mapNotLoggedIn); - return; - } - if (cooldownRemaining > 0) { - _snack(AppStrings.mapCooldownNotice(cooldownRemaining)); + final guard = riskPreconditionError( + loginDateTime: widget.loginDateTime, + loginId: widget.loginId, + cooldownRemaining: cooldownRemaining, + notLoggedInMessage: AppStrings.mapNotLoggedIn, + cooldownNotice: AppStrings.mapCooldownNotice, + ); + if (guard != null) { + _snack(guard); return; } if (_pending.isEmpty) { @@ -135,16 +130,20 @@ class _MapTraversePageState extends State return; } - setState(() => _running = true); + final loginDateTime = widget.loginDateTime!; + final loginId = widget.loginId!; + setRiskRunning(true); try { - final service = TitleApiService.fromHolder(cookies: widget.cookies)!; + final service = SessionModel.instance.createTitleService( + cookies: widget.cookies, + )!; final builder = UserAllPayloadBuilder(service.config); var data = _userAllData; var serverIds = _serverMapIds; if (data.isEmpty || serverIds == null) { - _updateStep(RiskStep.fetchData); + updateRiskStep(RiskStep.fetchData); final results = await Future.wait([ service.fetchUserAllData(widget.userId), service.getUserMaps(widget.userId), @@ -155,7 +154,7 @@ class _MapTraversePageState extends State serverIds = _serverMapIds!; } - _updateStep(RiskStep.upload); + updateRiskStep(RiskStep.upload); final packet = builder.build( userId: widget.userId, loginId: loginId, @@ -175,20 +174,12 @@ class _MapTraversePageState extends State builder.applyMapPatch(packet, maps: maps, newFlags: flags); await service.upsertUserAll(packet, widget.userId); - _updateStep(RiskStep.complete, await _verify(service)); - - if (_autoLogout && widget.onExitToTitle != null) { - await Future.delayed(const Duration(seconds: 2)); - if (!mounted) return; - _updateStep(RiskStep.logout, AppStrings.exitingToTitle); - await widget.onExitToTitle!(); - } - } on TitleApiException catch (e) { - _updateStep(RiskStep.failed, e.message); + updateRiskStep(RiskStep.complete, await _verify(service)); + await autoLogoutAndExit(widget.onExitToTitle); } catch (e) { - _updateStep(RiskStep.failed, '$e'); + reportRiskError(e); } finally { - if (mounted) setState(() => _running = false); + setRiskRunning(false); } } @@ -199,7 +190,7 @@ class _MapTraversePageState extends State try { rows = await service.getUserMaps(widget.userId); } catch (e) { - return AppStrings.mapVerifyFailed('$e'); + return AppStrings.mapVerifyFailed(describeApiError(e)); } if (mounted) setState(() => _applyServerMaps(rows)); @@ -211,91 +202,83 @@ class _MapTraversePageState extends State return AppStrings.mapVerified(requested.length); } - void _updateStep(RiskStep step, [String? message]) { - if (!mounted) return; - setState(() { - _step = step; - _stepMessage = message ?? ''; - }); - } - @override Widget build(BuildContext context) { final theme = Theme.of(context); return Scaffold( appBar: AppBar(title: const Text(AppStrings.mapFeatureTitle)), - body: SingleChildScrollView( - padding: const EdgeInsets.all(16), - child: responsiveBody( - context, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (!isLoggedIn) - const AppNotice(AppStrings.mapNotLoggedIn, error: true), - if (isLoggedIn && cooldownRemaining > 0) - AppNotice(AppStrings.mapCooldownNotice(cooldownRemaining)), - AppCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SectionTitle( - icon: Icons.map_outlined, - title: AppStrings.mapFeatureTitle, - ), - const SizedBox(height: 12), - Text( - AppStrings.mapFeatureDesc, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - height: 1.5, - ), - ), - const SizedBox(height: 10), - Text( - AppStrings.mapCollectiblesNotice, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.error, - height: 1.5, - ), - ), - ], - ), - ), - const SizedBox(height: 12), - _inputCard(theme), - const SizedBox(height: 12), - _fetchCard(theme), - const SizedBox(height: 12), - AutoLogoutToggle( - value: _autoLogout, - enabled: widget.onExitToTitle != null && !_running, - onChanged: (v) => setState(() => _autoLogout = v), - ), - const SizedBox(height: 12), - _runButton(theme), - if (_step != RiskStep.idle) ...[ - const SizedBox(height: 16), - StepProgressCard( - step: _step, - message: _stepMessage, - steps: [ - (RiskStep.fetchData, AppStrings.mapStepFetch), - (RiskStep.upload, AppStrings.mapStepUpload), - if (_autoLogout) (RiskStep.logout, AppStrings.stepLogout), - ], - ), - ], - ], + body: RiskPageBody( + children: [ + RiskStatusNotices( + isLoggedIn: isLoggedIn, + cooldownRemaining: cooldownRemaining, + notLoggedInText: AppStrings.mapNotLoggedIn, + cooldownText: AppStrings.mapCooldownNotice, ), - ), + RiskFeatureCard( + icon: Icons.map_outlined, + title: AppStrings.mapFeatureTitle, + description: AppStrings.mapFeatureDesc, + child: Padding( + padding: const EdgeInsets.only(top: 10), + child: Text( + AppStrings.mapCollectiblesNotice, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.error, + height: 1.5, + ), + ), + ), + ), + const SizedBox(height: 12), + _inputCard(theme), + const SizedBox(height: 12), + RiskFetchCard( + hasData: _serverMapIds != null, + statusText: _serverMapIds != null + ? '${AppStrings.mapFetched} (${_serverMaps.length})' + : AppStrings.mapNoData, + loadingLabel: AppStrings.travelPartnerFetching, + actionLabel: AppStrings.mapFetchData, + loading: riskFetching, + enabled: !riskRunning, + onFetch: _fetchData, + ), + const SizedBox(height: 12), + AutoLogoutToggle( + value: riskAutoLogout, + enabled: widget.onExitToTitle != null && !riskRunning, + onChanged: setRiskAutoLogout, + ), + const SizedBox(height: 12), + RiskRunButton( + running: riskRunning, + enabled: isLoggedIn, + icon: Icons.flag_outlined, + label: AppStrings.mapRun, + runningLabel: AppStrings.mapRunning, + onPressed: _run, + ), + if (riskStep != RiskStep.idle) ...[ + const SizedBox(height: 16), + StepProgressCard( + step: riskStep, + message: riskStepMessage, + steps: [ + (RiskStep.fetchData, AppStrings.mapStepFetch), + (RiskStep.upload, AppStrings.mapStepUpload), + if (riskAutoLogout) (RiskStep.logout, AppStrings.stepLogout), + ], + ), + ], + ], ), ); } Widget _inputCard(ThemeData theme) { - final enabled = !_running; + final enabled = !riskRunning; return AppCard( child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -341,82 +324,4 @@ class _MapTraversePageState extends State ), ); } - - 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), - ), - ), - ), - ); - } } diff --git a/lib/pages/music_risk_feature_view.dart b/lib/pages/music_risk_feature_view.dart index afc1d61..ffb4d17 100644 --- a/lib/pages/music_risk_feature_view.dart +++ b/lib/pages/music_risk_feature_view.dart @@ -1,13 +1,17 @@ import 'package:flutter/material.dart'; -import '../config/responsive.dart'; import '../config/strings.dart'; import '../config/title_server_config.dart'; +import '../models/session_model.dart'; import '../services/title_api_service.dart'; import '../services/user_all_payload_builder.dart'; import '../widgets/app_card.dart'; import '../widgets/app_notice.dart'; import '../widgets/cooldown_mixin.dart'; +import '../widgets/risk/music_risk_editors.dart'; +import '../widgets/risk/risk_list_widgets.dart'; +import '../widgets/risk/risk_run_mixin.dart'; +import '../widgets/risk/risk_widgets.dart'; import '../widgets/step_progress.dart'; /// Dart port of `eaquira/action/UnlockMusic.py`: @@ -25,39 +29,6 @@ enum MusicRiskFeatureMode { collectibles, } -/// 一行待提交的收藏品道具(对应 wire 上的 UserItem)。 -class _PendingItem { - final int kind; - final int id; - - const _PendingItem(this.kind, this.id); -} - -/// 一行待提交的歌曲解锁,展开成 itemKind 5/6/7 的若干行。 -class _PendingMusic { - final int musicId; - final bool base; - final bool master; - final bool remaster; - - const _PendingMusic(this.musicId, this.base, this.master, this.remaster); - - bool get hasAnyOption => base || master || remaster; - - /// 难度由 itemKind 表达,不写在 musicDetail 上。 - List get itemKinds => [ - if (base) 5, - if (master) 6, - if (remaster) 7, - ]; - - String get optionLabel => [ - if (base) '歌曲', - if (master) 'Master', - if (remaster) 'Re:Master', - ].join('/'); -} - class MusicRiskFeatureView extends StatefulWidget { final int userId; final String? cookies; @@ -96,106 +67,42 @@ class MusicRiskFeatureView extends StatefulWidget { } class _MusicRiskFeatureViewState extends State - with CooldownMixin { - // ── 解锁选项 (UnlockMusic) ── - final _unlockMusicIdController = TextEditingController(); - bool _unlockMusic = false; - bool _unlockMaster = false; - bool _unlockRemaster = false; - - // ── 收藏品选项 (Collectibles) ── - final _itemIdController = TextEditingController(); - int _itemKind = 3; - + with CooldownMixin, RiskRunMixin { // ── 待提交列表:userItemList 可以一次带多行 ── // 真客户端的 ExportUserItems() 就是把 Plate/Title/Icon/Partner/Frame/Ticket 与 // MusicUnlock/Master/Remaster 各列表拼成多行 UserItem,BuildListData 再只发增量、 // isNewItemList 一行一个字符。所以多行同类/异类混发是客户端自己的做法。 - final List<_PendingItem> _pendingItems = []; - final List<_PendingMusic> _pendingMusics = []; + final List _pendingItems = []; + final List _pendingMusics = []; - // ── 运行状态 ── Map>? _userAllData; - bool _fetching = false; String? _fetchError; - bool _running = false; - bool _autoLogout = true; - RiskStep _step = RiskStep.idle; - String _stepMessage = ''; - String? _error; bool get _isUnlock => widget.mode == MusicRiskFeatureMode.unlockMusic; - /// ConstParameter.DxScoreBaseID=10000 / StrongScoreBaseID=20000 - /// (`MAI2System/ConstParameter.cs:168,171`)。这段里的歌 Master 需要 - /// 显式 itemKind 6,不像 id < 10000 那样在 IsUnlockMaster 里直接放过。 - bool _isDeluxeRange(_PendingMusic music) => - music.musicId >= 10000 && music.musicId < 20000; - @override int? get cooldownLoginDateTime => widget.loginDateTime; - @override - void dispose() { - _unlockMusicIdController.dispose(); - _itemIdController.dispose(); - super.dispose(); - } - - void _updateStep(RiskStep step, [String? message]) { - if (!mounted) return; - setState(() { - _step = step; - _stepMessage = message ?? ''; - }); - } - - int _unlockMusicId() => - int.tryParse(_unlockMusicIdController.text.trim()) ?? -1; - - int _itemId() => int.tryParse(_itemIdController.text.trim()) ?? -1; - void _snack(String message) => context.showSnack(message); - /// 把当前输入框里的收藏品加入待提交列表。 - void _addItemToList() { - final id = _itemId(); - if (id <= 0) { - _snack(AppStrings.collectiblesNeedItemId); - return; - } - if (_pendingItems.any((e) => e.kind == _itemKind && e.id == id)) { + /// 加入待提交收藏品;重复时提示并保留输入。 + bool _addItem(PendingItem item) { + if (_pendingItems.any((e) => e.kind == item.kind && e.id == item.id)) { _snack(AppStrings.listDuplicate); - return; + return false; } - setState(() { - _pendingItems.add(_PendingItem(_itemKind, id)); - _itemIdController.clear(); - }); + setState(() => _pendingItems.add(item)); + return true; } - /// 把当前输入框里的歌曲+勾选的难度加入待解锁列表。 - void _addMusicToList() { - final id = _unlockMusicId(); - if (id <= 0) { - _snack(AppStrings.unlockNeedMusicId); - return; - } - if (!_unlockMusic && !_unlockMaster && !_unlockRemaster) { - _snack(AppStrings.unlockNeedOption); - return; - } - if (_pendingMusics.any((e) => e.musicId == id)) { + /// 加入待解锁歌曲;重复时提示并保留输入。 + bool _addMusic(PendingMusic music) { + if (_pendingMusics.any((e) => e.musicId == music.musicId)) { _snack(AppStrings.listDuplicate); - return; + return false; } - setState(() { - _pendingMusics.add( - _PendingMusic(id, _unlockMusic, _unlockMaster, _unlockRemaster), - ); - _unlockMusicIdController.clear(); - _unlockMusic = _unlockMaster = _unlockRemaster = false; - }); + setState(() => _pendingMusics.add(music)); + return true; } /// eaquira UnlockMusic.py 使用 settings 中的默认 musicData。 @@ -244,27 +151,29 @@ class _MusicRiskFeatureViewState extends State } Future _fetchData() async { - if (!TitleServerConfigHolder().isConfigured) return; + final service = SessionModel.instance.createTitleService( + cookies: widget.cookies, + ); + if (service == null) return; setState(() { - _fetching = true; + riskFetching = true; _fetchError = null; _userAllData = null; }); try { - final service = TitleApiService.fromHolder(cookies: widget.cookies)!; final data = await service.fetchUserAllData(widget.userId); if (!mounted) return; setState(() { _userAllData = data; - _fetching = false; + riskFetching = false; }); } catch (e) { if (!mounted) return; setState(() { - _fetchError = e.toString(); - _fetching = false; + _fetchError = describeApiError(e); + riskFetching = false; }); } } @@ -275,37 +184,36 @@ class _MusicRiskFeatureViewState extends State _snack(validationError); return; } - if (!TitleServerConfigHolder().isConfigured) { - _snack(AppStrings.ticketNotConfigured); - return; - } - final loginDateTime = widget.loginDateTime; - final loginId = widget.loginId; - if (loginDateTime == null || loginId == null) { - _snack(_notLoggedInMessage); - return; - } - if (cooldownRemaining > 0) { - _snack(_cooldownNotice(cooldownRemaining)); + final guard = riskPreconditionError( + loginDateTime: widget.loginDateTime, + loginId: widget.loginId, + cooldownRemaining: cooldownRemaining, + notLoggedInMessage: _notLoggedInMessage, + cooldownNotice: _cooldownNotice, + ); + if (guard != null) { + _snack(guard); return; } - final service = TitleApiService.fromHolder(cookies: widget.cookies)!; + final loginDateTime = widget.loginDateTime!; + final loginId = widget.loginId!; + final service = SessionModel.instance.createTitleService( + cookies: widget.cookies, + )!; - setState(() { - _running = true; - _error = null; - }); + setRiskRunning(true); + clearRiskError(); try { Map> data = _userAllData ?? const {}; if (data.isEmpty) { - _updateStep(RiskStep.fetchData); + updateRiskStep(RiskStep.fetchData); data = await service.fetchUserAllData(widget.userId); if (mounted) setState(() => _userAllData = data); } - _updateStep(RiskStep.upload); + updateRiskStep(RiskStep.upload); final musicData = _buildMusicData(); final builder = UserAllPayloadBuilder(service.config); final packet = builder.build( @@ -351,28 +259,19 @@ class _MusicRiskFeatureViewState extends State await service.upsertUserAll(packet, widget.userId); - _updateStep( + updateRiskStep( RiskStep.complete, _isUnlock ? await _verifyUnlock(service, items) : AppStrings.collectiblesSuccess, ); - if (_autoLogout && widget.onExitToTitle != null) { - // 让成功状态先显示一下,再交给 HomePage 结算、退登并 pop 回主标题。 - await Future.delayed(const Duration(seconds: 2)); - if (!mounted) return; - _updateStep(RiskStep.logout, AppStrings.exitingToTitle); - await widget.onExitToTitle!(); - } - } on TitleApiException catch (e) { - _updateStep(RiskStep.failed, e.message); - setState(() => _error = e.message); + // 让成功状态先显示一下,再交给 HomePage 结算、退登并 pop 回主标题。 + await autoLogoutAndExit(widget.onExitToTitle); } catch (e) { - _updateStep(RiskStep.failed, e.toString()); - setState(() => _error = e.toString()); + reportRiskError(e); } finally { - if (mounted) setState(() => _running = false); + setRiskRunning(false); } } @@ -394,7 +293,7 @@ class _MusicRiskFeatureViewState extends State items.map((e) => e['itemKind'] as int), ); } catch (e) { - return AppStrings.unlockVerifyFailed('$e'); + return AppStrings.unlockVerifyFailed(describeApiError(e)); } final missing = requested.where((k) => !saved.contains(k)).toList(); @@ -414,545 +313,142 @@ class _MusicRiskFeatureViewState extends State @override Widget build(BuildContext context) { - final theme = Theme.of(context); - if (!TitleServerConfigHolder().isConfigured) { - return Center( - child: Padding( - padding: const EdgeInsets.all(32), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.settings_ethernet, - size: 48, color: theme.colorScheme.primary), - const SizedBox(height: 16), - Text(AppStrings.ticketNotConfigured, - style: theme.textTheme.titleMedium), - ], - ), - ), + return const RiskConfigMissingView( + message: AppStrings.ticketNotConfigured, ); } - return SingleChildScrollView( - padding: const EdgeInsets.all(16), - child: responsiveBody( - context, - child: Column( - children: [ - if (!isLoggedIn) - AppNotice( - _notLoggedInMessage, - error: true, - icon: Icons.warning_amber_rounded, - ), - if (isLoggedIn && cooldownRemaining > 0) - AppNotice( - _cooldownNotice(cooldownRemaining), - icon: Icons.timer_outlined, - ), - _buildDescCard(theme), - const SizedBox(height: 12), - if (_isUnlock) - _buildUnlockCard(theme) - else - _buildCollectiblesCard(theme), - const SizedBox(height: 12), - _buildFetchCard(theme), - const SizedBox(height: 12), - AutoLogoutToggle( - value: _autoLogout, - enabled: widget.onExitToTitle != null && !_running, - onChanged: (v) => setState(() => _autoLogout = v), - ), - const SizedBox(height: 12), - _buildRunButton(theme), - if (_step != RiskStep.idle) ...[ - const SizedBox(height: 16), - StepProgressCard( - step: _step, - message: _stepMessage, - error: _error, - steps: [ - (RiskStep.fetchData, AppStrings.unlockStepFetch), - ( - RiskStep.upload, - _isUnlock - ? AppStrings.unlockStepUpload - : AppStrings.collectiblesStepUpload, - ), - if (_autoLogout) (RiskStep.logout, AppStrings.stepLogout), - ], - ), - ], - ], - ), - ), - ); - } - - Widget _buildDescCard(ThemeData theme) { - return Card( - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - side: BorderSide(color: theme.colorScheme.outline.withValues(alpha: 0.3)), - ), - child: Padding( - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon( - _isUnlock ? Icons.lock_open : Icons.card_giftcard, - size: 18, - color: theme.colorScheme.primary, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - widget.featureTitle, - style: theme.textTheme.labelLarge?.copyWith( - color: theme.colorScheme.primary, - fontWeight: FontWeight.w600, - ), - ), - ), - ], - ), - const SizedBox(height: 12), - Text( - widget.featureDesc, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - ); - } - - Widget _buildUnlockCard(ThemeData theme) { - final enabled = !_running; - - return Card( - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - side: BorderSide(color: theme.colorScheme.outline.withValues(alpha: 0.3)), - ), - child: Padding( - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - TextField( - controller: _unlockMusicIdController, - enabled: enabled, - keyboardType: TextInputType.number, - decoration: InputDecoration( - labelText: AppStrings.unlockMusicIdLabel, - hintText: AppStrings.unlockMusicIdHint, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - ), - contentPadding: const EdgeInsets.all(14), - ), - ), - const SizedBox(height: 6), - _buildUnlockCheckbox( - theme, - value: _unlockMusic, - label: AppStrings.unlockMusicOption, - enabled: enabled, - onChanged: (v) => setState(() => _unlockMusic = v), - ), - _buildUnlockCheckbox( - theme, - value: _unlockMaster, - label: AppStrings.unlockMasterOption, - enabled: enabled, - onChanged: (v) => setState(() => _unlockMaster = v), - ), - _buildUnlockCheckbox( - theme, - value: _unlockRemaster, - label: AppStrings.unlockRemasterOption, - enabled: enabled, - onChanged: (v) => setState(() => _unlockRemaster = v), - ), - const SizedBox(height: 8), - _buildAddButton(theme, onPressed: enabled ? _addMusicToList : null), - _buildPendingList( - theme, - title: AppStrings.unlockPendingTitle, - emptyHint: AppStrings.unlockPendingEmpty, - count: _pendingMusics.length, - rows: [ - for (var i = 0; i < _pendingMusics.length; i++) - _buildPendingRow( - theme, - leading: '#${_pendingMusics[i].musicId}', - trailing: _pendingMusics[i].optionLabel, - onRemove: () => setState(() => _pendingMusics.removeAt(i)), - ), - ], - ), - // DX 段的 Master / Re:Master 不白送,勾选项里漏一个就开不出来, - // 这是这段 ID 最容易踩的坑,只在列表真含 DX 曲时才说明。 - if (_pendingMusics.any(_isDeluxeRange)) ...[ - const SizedBox(height: 10), - Text( - AppStrings.unlockDxRangeNotice, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.error, - height: 1.5, - ), - ), - ], - ], - ), - ), - ); - } - - Widget _buildAddButton(ThemeData theme, {VoidCallback? onPressed}) { - return SizedBox( - width: double.infinity, - child: OutlinedButton.icon( - onPressed: onPressed, - icon: const Icon(Icons.add, size: 18), - label: const Text(AppStrings.listAddButton), - style: OutlinedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 12), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), - ), - ), - ); - } - - Widget _buildPendingList( - ThemeData theme, { - required String title, - required String emptyHint, - required int count, - required List rows, - }) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, + return RiskPageBody( children: [ - const SizedBox(height: 14), - Text( - '$title ($count)', - style: theme.textTheme.labelLarge?.copyWith( - color: theme.colorScheme.primary, - fontWeight: FontWeight.w600, - ), + RiskStatusNotices( + isLoggedIn: isLoggedIn, + cooldownRemaining: cooldownRemaining, + notLoggedInText: _notLoggedInMessage, + cooldownText: _cooldownNotice, ), - const SizedBox(height: 6), - if (count == 0) - Text( - emptyHint, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), + RiskFeatureCard( + icon: _isUnlock ? Icons.lock_open : Icons.card_giftcard, + title: widget.featureTitle, + description: widget.featureDesc, + ), + const SizedBox(height: 12), + if (_isUnlock) + UnlockMusicEditorCard( + enabled: !riskRunning, + onAdd: _addMusic, + onError: _snack, + pendingList: _buildUnlockPendingList(), + showDxNotice: _pendingMusics.any((m) => m.isDeluxeRange), ) else - ...rows, - ], - ); - } - - Widget _buildPendingRow( - ThemeData theme, { - required String leading, - required String trailing, - required VoidCallback onRemove, - }) { - return Row( - children: [ - Expanded( - child: Text( - '$leading $trailing', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodySmall?.copyWith( - fontFamily: 'monospace', - ), + CollectiblesEditorCard( + enabled: !riskRunning, + onAdd: _addItem, + onError: _snack, + pendingList: _buildCollectiblesPendingList(), ), + const SizedBox(height: 12), + _buildFetchCard(), + const SizedBox(height: 12), + AutoLogoutToggle( + value: riskAutoLogout, + enabled: widget.onExitToTitle != null && !riskRunning, + onChanged: setRiskAutoLogout, ), - IconButton( - icon: const Icon(Icons.close, size: 18), - tooltip: AppStrings.listRemoveTooltip, - onPressed: _running ? null : onRemove, - color: theme.colorScheme.onSurfaceVariant, - visualDensity: VisualDensity.compact, - ), + const SizedBox(height: 12), + _buildRunButton(), + if (riskStep != RiskStep.idle) ...[ + const SizedBox(height: 16), + StepProgressCard( + step: riskStep, + message: riskStepMessage, + error: riskError, + steps: [ + (RiskStep.fetchData, AppStrings.unlockStepFetch), + ( + RiskStep.upload, + _isUnlock + ? AppStrings.unlockStepUpload + : AppStrings.collectiblesStepUpload, + ), + if (riskAutoLogout) (RiskStep.logout, AppStrings.stepLogout), + ], + ), + ], ], ); } - Widget _buildUnlockCheckbox( - ThemeData theme, { - required bool value, - required String label, - required bool enabled, - required ValueChanged onChanged, - }) { - return InkWell( - borderRadius: BorderRadius.circular(10), - onTap: enabled ? () => onChanged(!value) : null, - child: Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2), - child: Row( - children: [ - Checkbox( - value: value, - onChanged: enabled ? (v) => onChanged(v ?? false) : null, - ), - Expanded( - child: Text( - label, - style: theme.textTheme.bodyMedium?.copyWith( - color: enabled - ? null - : theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.6), - ), - ), - ), - ], - ), - ), + Widget _buildUnlockPendingList() { + return RiskPendingList( + title: AppStrings.unlockPendingTitle, + emptyHint: AppStrings.unlockPendingEmpty, + count: _pendingMusics.length, + rows: [ + for (var i = 0; i < _pendingMusics.length; i++) + RiskPendingRow( + leading: '#${_pendingMusics[i].musicId}', + trailing: _pendingMusics[i].optionLabel, + enabled: !riskRunning, + onRemove: () => setState(() => _pendingMusics.removeAt(i)), + ), + ], ); } - Widget _buildCollectiblesCard(ThemeData theme) { - final enabled = !_running; - - return Card( - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - side: BorderSide(color: theme.colorScheme.outline.withValues(alpha: 0.3)), - ), - child: Padding( - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildDropdown( - value: _itemKind, - label: AppStrings.collectiblesItemKindLabel, - items: [ - for (final kind in AppStrings.collectiblesItemKinds) - DropdownMenuItem( - value: kind, - child: Text( - '${AppStrings.collectiblesItemKindName(kind)} ($kind)', - ), - ), - ], - onChanged: (v) => setState(() => _itemKind = v), - ), - const SizedBox(height: 12), - TextField( - controller: _itemIdController, - enabled: enabled, - keyboardType: TextInputType.number, - decoration: InputDecoration( - labelText: AppStrings.collectiblesItemIdLabel, - hintText: AppStrings.collectiblesItemIdHint, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - ), - contentPadding: const EdgeInsets.all(14), - ), - ), - const SizedBox(height: 8), - _buildAddButton(theme, onPressed: enabled ? _addItemToList : null), - _buildPendingList( - theme, - title: AppStrings.collectiblesPendingTitle, - emptyHint: AppStrings.collectiblesPendingEmpty, - count: _pendingItems.length, - rows: [ - for (var i = 0; i < _pendingItems.length; i++) - _buildPendingRow( - theme, - leading: - '${AppStrings.collectiblesItemKindName(_pendingItems[i].kind)} (${_pendingItems[i].kind})', - trailing: '#${_pendingItems[i].id}', - onRemove: () => setState(() => _pendingItems.removeAt(i)), - ), - ], - ), - ], - ), - ), + Widget _buildCollectiblesPendingList() { + return RiskPendingList( + title: AppStrings.collectiblesPendingTitle, + emptyHint: AppStrings.collectiblesPendingEmpty, + count: _pendingItems.length, + rows: [ + for (var i = 0; i < _pendingItems.length; i++) + RiskPendingRow( + leading: + '${AppStrings.collectiblesItemKindName(_pendingItems[i].kind)} (${_pendingItems[i].kind})', + trailing: '#${_pendingItems[i].id}', + enabled: !riskRunning, + onRemove: () => setState(() => _pendingItems.removeAt(i)), + ), + ], ); } - Widget _buildFetchCard(ThemeData theme) { + Widget _buildFetchCard() { final hasData = _userAllData != null; - return Card( - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - side: BorderSide( - color: hasData - ? Colors.green.withValues(alpha: 0.5) - : theme.colorScheme.outline.withValues(alpha: 0.3), - ), - ), - child: Padding( - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon( - hasData ? Icons.check_circle : Icons.download, - size: 18, - color: hasData ? Colors.green : theme.colorScheme.primary, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - hasData - ? AppStrings.unlockFetched - : AppStrings.unlockNoData, - style: theme.textTheme.bodySmall?.copyWith( - color: hasData - ? Colors.green - : theme.colorScheme.onSurfaceVariant, - ), - ), - ), - SizedBox( - height: 32, - child: OutlinedButton.icon( - onPressed: _fetching || _running ? null : _fetchData, - icon: _fetching - ? const SizedBox( - width: 14, - height: 14, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.refresh, size: 16), - label: Text( - _fetching - ? AppStrings.unlockFetching - : hasData - ? AppStrings.unlockRefetch - : AppStrings.unlockFetchData, - style: theme.textTheme.labelSmall, - ), - style: OutlinedButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 12), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - ), - ), - ], - ), - if (_fetchError != null) ...[ - const SizedBox(height: 12), - Container( - width: double.infinity, - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: theme.colorScheme.errorContainer, - borderRadius: BorderRadius.circular(8), - ), - child: Text( - _fetchError!, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onErrorContainer, - fontFamily: 'monospace', - ), - ), - ), - ], - ], - ), - ), + return RiskFetchCard( + hasData: hasData, + statusText: + hasData ? AppStrings.unlockFetched : AppStrings.unlockNoData, + loadingLabel: AppStrings.unlockFetching, + actionLabel: + hasData ? AppStrings.unlockRefetch : AppStrings.unlockFetchData, + error: _fetchError, + loading: riskFetching, + enabled: !riskRunning, + onFetch: _fetchData, ); } - Widget _buildRunButton(ThemeData theme) { + Widget _buildRunButton() { final onCooldown = cooldownRemaining > 0; final canRun = widget.loginDateTime != null && widget.loginId != null && - !_running && !onCooldown; - return SizedBox( - width: double.infinity, - child: FilledButton.icon( - onPressed: canRun ? _run : null, - icon: _running - ? const SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), - ) - : Icon( - onCooldown - ? Icons.timer_outlined - : (_isUnlock ? Icons.lock_open : Icons.card_giftcard), - size: 20, - ), - label: Text( - _running - ? AppStrings.unlockRunning - : onCooldown - ? AppStrings.ticketCooldownCountdown(cooldownRemaining) - : _isUnlock - ? AppStrings.unlockMusicRun - : AppStrings.collectiblesRun, - ), - style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 14), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - ), - ); - } - - Widget _buildDropdown({ - required int value, - required String label, - required List> items, - required ValueChanged onChanged, - }) { - return DropdownButtonFormField( - initialValue: value, - decoration: InputDecoration( - labelText: label, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - ), - contentPadding: const EdgeInsets.all(14), - ), - items: items, - onChanged: _running - ? null - : (v) { - if (v == null) return; - onChanged(v); - }, + return RiskRunButton( + running: riskRunning, + enabled: canRun, + icon: onCooldown + ? Icons.timer_outlined + : (_isUnlock ? Icons.lock_open : Icons.card_giftcard), + label: _isUnlock ? AppStrings.unlockMusicRun : AppStrings.collectiblesRun, + runningLabel: AppStrings.unlockRunning, + disabledLabel: + onCooldown ? AppStrings.ticketCooldownCountdown(cooldownRemaining) : null, + onPressed: _run, ); } } diff --git a/lib/pages/number_patch_page.dart b/lib/pages/number_patch_page.dart index d80250b..45d927b 100644 --- a/lib/pages/number_patch_page.dart +++ b/lib/pages/number_patch_page.dart @@ -1,14 +1,16 @@ import 'package:flutter/material.dart'; -import '../config/responsive.dart'; import '../config/strings.dart'; -import '../config/title_server_config.dart'; +import '../core/json_value.dart'; +import '../models/session_model.dart'; import '../services/title_api_service.dart'; import '../services/user_all_payload_builder.dart'; import '../widgets/app_card.dart'; import '../widgets/app_notice.dart'; import '../widgets/cooldown_mixin.dart'; +import '../widgets/risk/risk_run_mixin.dart'; +import '../widgets/risk/risk_widgets.dart'; import '../widgets/step_progress.dart'; /// 数值修改:Rating 与舞里程。两者的流程一模一样(拉数据 → 改一个数 → @@ -98,18 +100,12 @@ class NumberPatchView extends StatefulWidget { } class _NumberPatchViewState extends State - with CooldownMixin { + with CooldownMixin, RiskRunMixin { final _valueController = TextEditingController(); Map> _userAllData = const {}; bool _maiMileAdd = true; - bool _loading = false; - bool _running = false; - bool _autoLogout = true; - RiskStep _step = RiskStep.idle; - String _stepMessage = ''; - bool get _isRating => widget.mode == NumberPatchMode.rating; @override @@ -123,18 +119,11 @@ class _NumberPatchViewState extends State void _snack(String message) => context.showSnack(message); - static int _toInt(dynamic v) { - if (v is int) return v; - if (v is num) return v.toInt(); - if (v is String) return int.tryParse(v) ?? 0; - return 0; - } - /// 从已拉取的 GetUserDataApi 里读当前值,供「累加」和界面上的当前值展示。 int _currentOf(Map> data, String field) { final userData = data['GetUserDataApi']?['userData']; if (userData is! Map) return 0; - return _toInt(userData[field]); + return jsonInt(userData[field]); } int get _currentRating => _currentOf(_userAllData, 'playerRating'); @@ -172,37 +161,36 @@ class _NumberPatchViewState extends State } Future _fetchData() async { - if (!TitleServerConfigHolder().isConfigured) return; - setState(() => _loading = true); + final service = SessionModel.instance.createTitleService( + cookies: widget.cookies, + ); + if (service == null) return; + setRiskFetching(true); try { - final service = TitleApiService.fromHolder(cookies: widget.cookies)!; final data = await service.fetchUserAllData(widget.userId); if (!mounted) return; setState(() { _userAllData = data; - _loading = false; + riskFetching = false; }); } catch (e) { if (!mounted) return; - setState(() => _loading = false); - _snack('${AppStrings.loadFailed}: $e'); + setState(() => riskFetching = false); + _snack('${AppStrings.loadFailed}: ${describeApiError(e)}'); } } Future _run() async { - if (!TitleServerConfigHolder().isConfigured) { - _snack(AppStrings.ticketNotConfigured); - return; - } - final loginDateTime = widget.loginDateTime; - final loginId = widget.loginId; - if (loginDateTime == null || loginId == null) { - _snack(AppStrings.numberPatchNotLoggedIn); - return; - } - if (cooldownRemaining > 0) { - _snack(AppStrings.numberPatchCooldownNotice(cooldownRemaining)); + final guard = riskPreconditionError( + loginDateTime: widget.loginDateTime, + loginId: widget.loginId, + cooldownRemaining: cooldownRemaining, + notLoggedInMessage: AppStrings.numberPatchNotLoggedIn, + cooldownNotice: AppStrings.numberPatchCooldownNotice, + ); + if (guard != null) { + _snack(guard); return; } final inputError = _checkInput(); @@ -211,21 +199,26 @@ class _NumberPatchViewState extends State return; } final input = int.parse(_valueController.text.trim()); + final loginDateTime = widget.loginDateTime!; + final loginId = widget.loginId!; - setState(() => _running = true); + setRiskRunning(true); + clearRiskError(); try { - final service = TitleApiService.fromHolder(cookies: widget.cookies)!; + final service = SessionModel.instance.createTitleService( + cookies: widget.cookies, + )!; final builder = UserAllPayloadBuilder(service.config); var data = _userAllData; if (data.isEmpty) { - _updateStep(RiskStep.fetchData); + updateRiskStep(RiskStep.fetchData); data = await service.fetchUserAllData(widget.userId); if (mounted) setState(() => _userAllData = data); } - _updateStep(RiskStep.upload); + updateRiskStep(RiskStep.upload); final packet = builder.build( userId: widget.userId, loginId: loginId, @@ -237,8 +230,7 @@ class _NumberPatchViewState extends State final String resultMessage; if (_isRating) { builder.applyRatingPatch(packet, rating: input); - resultMessage = - '${AppStrings.ratingSuccess} → ${_clamp(input)}'; + resultMessage = '${AppStrings.ratingSuccess} → ${_clamp(input)}'; } else { final current = _currentOf(data, 'point'); final total = _currentOf(data, 'totalPoint'); @@ -253,103 +245,70 @@ class _NumberPatchViewState extends State } await service.upsertUserAll(packet, widget.userId); - _updateStep(RiskStep.complete, resultMessage); - - if (_autoLogout && widget.onExitToTitle != null) { - await Future.delayed(const Duration(seconds: 2)); - if (!mounted) return; - _updateStep(RiskStep.logout, AppStrings.exitingToTitle); - await widget.onExitToTitle!(); - } - } on TitleApiException catch (e) { - _updateStep(RiskStep.failed, e.message); + updateRiskStep(RiskStep.complete, resultMessage); + await autoLogoutAndExit(widget.onExitToTitle); } catch (e) { - _updateStep(RiskStep.failed, '$e'); + reportRiskError(e); } finally { - if (mounted) setState(() => _running = false); + setRiskRunning(false); } } - void _updateStep(RiskStep step, [String? message]) { - if (!mounted) return; - setState(() { - _step = step; - _stepMessage = message ?? ''; - }); - } - @override Widget build(BuildContext context) { final theme = Theme.of(context); - return SingleChildScrollView( - padding: const EdgeInsets.all(16), - child: responsiveBody( - context, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (!isLoggedIn) - const AppNotice(AppStrings.numberPatchNotLoggedIn, error: true), - if (isLoggedIn && cooldownRemaining > 0) - AppNotice( - AppStrings.numberPatchCooldownNotice(cooldownRemaining), - ), - _descCard(theme), - const SizedBox(height: 12), - _inputCard(theme), - const SizedBox(height: 12), - _fetchCard(theme), - const SizedBox(height: 12), - AutoLogoutToggle( - value: _autoLogout, - enabled: widget.onExitToTitle != null && !_running, - onChanged: (v) => setState(() => _autoLogout = v), - ), - const SizedBox(height: 12), - _runButton(theme), - if (_step != RiskStep.idle) ...[ - const SizedBox(height: 16), - StepProgressCard( - step: _step, - message: _stepMessage, - steps: [ - (RiskStep.fetchData, AppStrings.unlockStepFetch), - (RiskStep.upload, AppStrings.numberPatchStepUpload), - if (_autoLogout) (RiskStep.logout, AppStrings.stepLogout), - ], - ), - ], - ], + return RiskPageBody( + children: [ + RiskStatusNotices( + isLoggedIn: isLoggedIn, + cooldownRemaining: cooldownRemaining, + notLoggedInText: AppStrings.numberPatchNotLoggedIn, + cooldownText: AppStrings.numberPatchCooldownNotice, ), - ), - ); - } - - Widget _descCard(ThemeData theme) { - return AppCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SectionTitle( - icon: _isRating ? Icons.speed : Icons.commute, - title: widget.featureTitle, - ), - const SizedBox(height: 12), - Text( - widget.featureDesc, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - height: 1.5, - ), + RiskFeatureCard( + icon: _isRating ? Icons.speed : Icons.commute, + title: widget.featureTitle, + description: widget.featureDesc, + ), + const SizedBox(height: 12), + _inputCard(theme), + const SizedBox(height: 12), + _fetchCard(), + const SizedBox(height: 12), + AutoLogoutToggle( + value: riskAutoLogout, + enabled: widget.onExitToTitle != null && !riskRunning, + onChanged: setRiskAutoLogout, + ), + const SizedBox(height: 12), + RiskRunButton( + running: riskRunning, + enabled: isLoggedIn, + icon: Icons.send, + label: AppStrings.numberPatchRun, + runningLabel: AppStrings.unlockRunning, + onPressed: _run, + ), + if (riskStep != RiskStep.idle) ...[ + const SizedBox(height: 16), + StepProgressCard( + step: riskStep, + message: riskStepMessage, + error: riskError, + steps: [ + (RiskStep.fetchData, AppStrings.unlockStepFetch), + (RiskStep.upload, AppStrings.numberPatchStepUpload), + if (riskAutoLogout) (RiskStep.logout, AppStrings.stepLogout), + ], ), ], - ), + ], ); } Widget _inputCard(ThemeData theme) { - final enabled = !_running; + final enabled = !riskRunning; final hasInput = int.tryParse(_valueController.text.trim()) != null; return AppCard( @@ -430,89 +389,22 @@ class _NumberPatchViewState extends State return AppStrings.numberPatchPreview(label, current, _previewTarget); } - Widget _fetchCard(ThemeData theme) { + Widget _fetchCard() { final hasData = _userAllData.isNotEmpty; final current = _isRating ? _currentRating : _currentPoint; final label = _isRating ? AppStrings.ratingCurrentValue : AppStrings.maiMileCurrentBalance; - return AppCard( - child: Row( - children: [ - Icon( - hasData ? Icons.check_circle : Icons.download, - size: 18, - color: hasData ? Colors.green : theme.colorScheme.primary, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - hasData - ? '$label: $current' - : AppStrings.unlockNoData, - style: theme.textTheme.bodySmall?.copyWith( - fontFamily: 'monospace', - color: hasData - ? Colors.green - : theme.colorScheme.onSurfaceVariant, - ), - ), - ), - SizedBox( - height: 32, - child: OutlinedButton.icon( - onPressed: _loading || _running ? null : _fetchData, - icon: _loading - ? const SizedBox( - width: 14, - height: 14, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.refresh, size: 16), - label: Text( - _loading - ? AppStrings.unlockFetching - : hasData - ? AppStrings.unlockRefetch - : AppStrings.unlockFetchData, - style: theme.textTheme.labelSmall, - ), - style: OutlinedButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 12), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - ), - ), - ], - ), - ); - } - - Widget _runButton(ThemeData theme) { - return SizedBox( - width: double.infinity, - child: FilledButton.icon( - onPressed: _running || !isLoggedIn ? null : _run, - icon: _running - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.send, size: 20), - label: Text( - _running ? AppStrings.unlockRunning : AppStrings.numberPatchRun, - ), - style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 14), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - ), + return RiskFetchCard( + hasData: hasData, + statusText: hasData ? '$label: $current' : AppStrings.unlockNoData, + loadingLabel: AppStrings.unlockFetching, + actionLabel: + hasData ? AppStrings.unlockRefetch : AppStrings.unlockFetchData, + loading: riskFetching, + enabled: !riskRunning, + onFetch: _fetchData, ); } } diff --git a/lib/pages/settings_page.dart b/lib/pages/settings_page.dart index d6eda9b..646ac4b 100644 --- a/lib/pages/settings_page.dart +++ b/lib/pages/settings_page.dart @@ -27,8 +27,6 @@ class _SettingsPageState extends State { final _regionNameController = TextEditingController(); final _placeNameController = TextEditingController(); final _keychipIdController = TextEditingController(); - final _aimeUrlController = TextEditingController(); - final _aimeSaltController = TextEditingController(); @override void initState() { @@ -50,8 +48,6 @@ class _SettingsPageState extends State { _regionNameController.text = config.regionName; _placeNameController.text = config.placeName; _keychipIdController.text = config.keychipId; - _aimeUrlController.text = config.aimeUrl; - _aimeSaltController.text = config.aimeSalt; } } @@ -68,8 +64,6 @@ class _SettingsPageState extends State { _regionNameController.dispose(); _placeNameController.dispose(); _keychipIdController.dispose(); - _aimeUrlController.dispose(); - _aimeSaltController.dispose(); super.dispose(); } @@ -88,11 +82,19 @@ class _SettingsPageState extends State { regionName: _regionNameController.text.trim(), placeName: _placeNameController.text.trim(), keychipId: _keychipIdController.text.trim(), - aimeUrl: _aimeUrlController.text.trim(), - aimeSalt: _aimeSaltController.text.trim(), )); - if (mounted) Navigator.of(context).pop(); + if (!mounted) return; + final navigator = Navigator.of(context); + // 作为主页页签嵌入时(showAppBar: false)没有可弹出的路由, + // 直接 pop 会把根路由弹掉,整个导航栈变空只剩黑屏。 + if (navigator.canPop()) { + navigator.pop(); + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text(AppStrings.configSaved)), + ); + } } @override @@ -139,11 +141,11 @@ class _SettingsPageState extends State { ]), const SizedBox(height: 24), _buildSection(theme, AppStrings.authServerSettings, [ - _buildField(controller: _keychipIdController, label: AppStrings.labelKeychipId), - const SizedBox(height: 14), - _buildField(controller: _aimeUrlController, label: AppStrings.labelAimeUrl), - const SizedBox(height: 14), - _buildField(controller: _aimeSaltController, label: AppStrings.labelAimeSalt), + _buildField( + controller: _keychipIdController, + label: AppStrings.labelKeychipId, + help: AppStrings.helpKeychipId, + ), ]), const SizedBox(height: 32), FilledButton.icon( @@ -216,11 +218,14 @@ class _SettingsPageState extends State { Widget _buildField({ required TextEditingController controller, required String label, + String? help, }) { return TextFormField( controller: controller, decoration: InputDecoration( labelText: label, + helperText: help, + helperStyle: const TextStyle(fontSize: 12), border: OutlineInputBorder( borderRadius: BorderRadius.circular(10), ), diff --git a/lib/pages/ticket_page.dart b/lib/pages/ticket_page.dart index d0f301d..0d4ad68 100644 --- a/lib/pages/ticket_page.dart +++ b/lib/pages/ticket_page.dart @@ -1,12 +1,14 @@ import 'package:flutter/material.dart'; -import '../config/responsive.dart'; import '../config/strings.dart'; import '../config/title_server_config.dart'; +import '../models/session_model.dart'; import '../services/title_api_service.dart'; import '../widgets/app_card.dart'; import '../widgets/app_notice.dart'; import '../widgets/cooldown_mixin.dart'; +import '../widgets/risk/risk_run_mixin.dart'; +import '../widgets/risk/risk_widgets.dart'; import '../widgets/step_progress.dart'; class TicketPage extends StatefulWidget { @@ -35,19 +37,13 @@ class TicketPage extends StatefulWidget { } class _TicketPageState extends State - with CooldownMixin { + with CooldownMixin, RiskRunMixin { static const _ticketNameMap = { 2: '2倍票', 4: '4倍票', 5: '5倍票' }; - RiskStep _step = RiskStep.idle; - String _stepMessage = ''; - String? _error; - bool _running = false; - bool _autoLogout = true; - List>? _tickets; bool _ticketsLoading = false; String? _ticketsError; @@ -70,16 +66,11 @@ class _TicketPageState extends State return null; } - void _updateStep(RiskStep step, [String? message]) { - if (!mounted) return; - setState(() { - _step = step; - _stepMessage = message ?? ''; - }); - } - Future _loadTickets() async { - if (!TitleServerConfigHolder().isConfigured) return; + final service = SessionModel.instance.createTitleService( + cookies: widget.cookies, + ); + if (service == null) return; setState(() { _ticketsLoading = true; @@ -89,7 +80,6 @@ class _TicketPageState extends State }); try { - final service = TitleApiService.fromHolder(cookies: widget.cookies)!; final data = await service.getUserCharge(widget.userId); if (!mounted) return; @@ -101,73 +91,67 @@ class _TicketPageState extends State } catch (e) { if (!mounted) return; setState(() { - _ticketsError = e.toString(); + _ticketsError = describeApiError(e); _ticketsLoading = false; }); } } Future _runTicket() async { - if (_selectedTicketId == null) { + final ticketId = _selectedTicketId; + if (ticketId == null) { _snack(AppStrings.ticketNotSelected); return; } - if (!TitleServerConfigHolder().isConfigured) { - _snack(AppStrings.ticketNotConfigured); - return; - } - final loginDateTime = widget.loginDateTime; - if (loginDateTime == null) { - _snack(AppStrings.ticketNotLoggedIn); - return; - } - if (cooldownRemaining > 0) { - _snack(AppStrings.ticketCooldownNotice(cooldownRemaining)); + final guard = riskPreconditionError( + loginDateTime: widget.loginDateTime, + loginId: null, + cooldownRemaining: cooldownRemaining, + notLoggedInMessage: AppStrings.ticketNotLoggedIn, + cooldownNotice: AppStrings.ticketCooldownNotice, + requireLoginId: false, + ); + if (guard != null) { + _snack(guard); return; } - final selectedTicket = _findTicket(_selectedTicketId!); + final selectedTicket = _findTicket(ticketId); if (selectedTicket != null && (selectedTicket['stock'] as num?)?.toInt() != 0) { _snack(AppStrings.ticketStockNotEmpty); return; } - final service = TitleApiService.fromHolder(cookies: widget.cookies)!; - final ticketId = _selectedTicketId!; - - setState(() { - _running = true; - _error = null; - }); + final service = SessionModel.instance.createTitleService( + cookies: widget.cookies, + )!; + setRiskRunning(true); + clearRiskError(); try { - _updateStep(RiskStep.upload); + updateRiskStep(RiskStep.upload); await service.upsertUserChargeLog( userId: widget.userId, ticketId: ticketId, - loginDateTime: loginDateTime, + loginDateTime: widget.loginDateTime!, playerRating: widget.playerRating, ); - if (_autoLogout && widget.onLogoutRequested != null) { - _updateStep(RiskStep.logout); - await Future.delayed(const Duration(seconds: 5)); + if (riskAutoLogout && widget.onLogoutRequested != null) { + updateRiskStep(RiskStep.logout); + await Future.delayed(const Duration(seconds: 5)); await widget.onLogoutRequested!(); } - _updateStep( + updateRiskStep( RiskStep.complete, '${AppStrings.ticketUsed}: ${_ticketName(ticketId)}', ); - } on TitleApiException catch (e) { - _updateStep(RiskStep.failed, e.message); - setState(() => _error = e.message); } catch (e) { - _updateStep(RiskStep.failed, e.toString()); - setState(() => _error = e.toString()); + reportRiskError(e); } finally { - if (mounted) setState(() => _running = false); + setRiskRunning(false); } } @@ -176,63 +160,46 @@ class _TicketPageState extends State final theme = Theme.of(context); if (!TitleServerConfigHolder().isConfigured) { - return Center( - child: Padding( - padding: const EdgeInsets.all(32), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.settings_ethernet, size: 48, color: theme.colorScheme.primary), - const SizedBox(height: 16), - Text(AppStrings.ticketNotConfigured, style: theme.textTheme.titleMedium), - ], - ), - ), + return const RiskConfigMissingView( + message: AppStrings.ticketNotConfigured, ); } - return SingleChildScrollView( - padding: const EdgeInsets.all(16), - child: responsiveBody( - context, - child: Column( - children: [ - if (!isLoggedIn) - const AppNotice( - AppStrings.ticketNotLoggedIn, - error: true, - icon: Icons.warning_amber_rounded, - ), - if (isLoggedIn && cooldownRemaining > 0) - AppNotice( - AppStrings.ticketCooldownNotice(cooldownRemaining), - icon: Icons.timer_outlined, - ), - _buildTicketListCard(theme), - const SizedBox(height: 16), - AutoLogoutToggle( - value: _autoLogout, - enabled: widget.onLogoutRequested != null && !_running, - onChanged: (v) => setState(() => _autoLogout = v), - label: AppStrings.ticketAutoLogout, - ), - const SizedBox(height: 12), - _buildRunButton(theme), - if (_step != RiskStep.idle) ...[ - const SizedBox(height: 16), - StepProgressCard( - step: _step, - message: _stepMessage, - error: _error, - steps: [ - (RiskStep.upload, AppStrings.stepChargeTicket), - if (_autoLogout) (RiskStep.logout, AppStrings.stepLogout), - ], - ), - ], - ], + return _buildBody(theme); + } + + Widget _buildBody(ThemeData theme) { + return RiskPageBody( + children: [ + RiskStatusNotices( + isLoggedIn: isLoggedIn, + cooldownRemaining: cooldownRemaining, + notLoggedInText: AppStrings.ticketNotLoggedIn, + cooldownText: AppStrings.ticketCooldownNotice, ), - ), + _buildTicketListCard(theme), + const SizedBox(height: 16), + AutoLogoutToggle( + value: riskAutoLogout, + enabled: widget.onLogoutRequested != null && !riskRunning, + onChanged: setRiskAutoLogout, + label: AppStrings.ticketAutoLogout, + ), + const SizedBox(height: 12), + _buildRunButton(theme), + if (riskStep != RiskStep.idle) ...[ + const SizedBox(height: 16), + StepProgressCard( + step: riskStep, + message: riskStepMessage, + error: riskError, + steps: [ + (RiskStep.upload, AppStrings.stepChargeTicket), + if (riskAutoLogout) (RiskStep.logout, AppStrings.stepLogout), + ], + ), + ], + ], ); } @@ -327,7 +294,7 @@ class _TicketPageState extends State return InkWell( borderRadius: BorderRadius.circular(8), - onTap: _running + onTap: riskRunning ? null : () { setState(() { @@ -415,7 +382,7 @@ class _TicketPageState extends State ? _ticketName(_selectedTicketId!) : null; final onCooldown = cooldownRemaining > 0; - final canRun = widget.loginDateTime != null && !_running && !onCooldown; + final canRun = widget.loginDateTime != null && !onCooldown; return Column( children: [ @@ -437,34 +404,18 @@ class _TicketPageState extends State ), const SizedBox(height: 12), ], - SizedBox( - width: double.infinity, - child: FilledButton.icon( - onPressed: canRun ? _runTicket : null, - icon: _running - ? const SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), - ) - : Icon(onCooldown ? Icons.timer_outlined : Icons.play_arrow, size: 20), - label: Text( - _running - ? AppStrings.runningTicket - : onCooldown - ? AppStrings.ticketCooldownCountdown(cooldownRemaining) - : AppStrings.runTicket, - ), - style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 14), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - ), + RiskRunButton( + running: riskRunning, + enabled: canRun, + icon: onCooldown ? Icons.timer_outlined : Icons.play_arrow, + label: AppStrings.runTicket, + runningLabel: AppStrings.runningTicket, + disabledLabel: onCooldown + ? AppStrings.ticketCooldownCountdown(cooldownRemaining) + : null, + onPressed: _runTicket, ), ], ); } - } diff --git a/lib/pages/travel_partner_page.dart b/lib/pages/travel_partner_page.dart index ba238e6..77ea67f 100644 --- a/lib/pages/travel_partner_page.dart +++ b/lib/pages/travel_partner_page.dart @@ -1,15 +1,16 @@ import 'package:flutter/material.dart'; -import '../config/responsive.dart'; import '../config/strings.dart'; -import '../config/title_server_config.dart'; +import '../models/session_model.dart'; import '../models/user_character.dart'; import '../services/title_api_service.dart'; import '../services/user_all_payload_builder.dart'; import '../widgets/app_card.dart'; import '../widgets/app_notice.dart'; import '../widgets/cooldown_mixin.dart'; +import '../widgets/risk/risk_run_mixin.dart'; +import '../widgets/risk/risk_widgets.dart'; import '../widgets/step_progress.dart'; /// 旅行伙伴页:发放角色 + 编组出战槽位。 @@ -44,7 +45,7 @@ class TravelPartnerPage extends StatefulWidget { } class _TravelPartnerPageState extends State - with CooldownMixin { + with CooldownMixin, RiskRunMixin { final _grantIdController = TextEditingController(); /// 每个出战槽位一个等级输入框,留空表示不改该角色的等级。 @@ -58,12 +59,6 @@ class _TravelPartnerPageState extends State final List _pendingGrant = []; List _slots = normalizeCharaSlot(const []); - bool _loading = false; - bool _running = false; - bool _autoLogout = true; - RiskStep _step = RiskStep.idle; - String _stepMessage = ''; - @override int? get cooldownLoginDateTime => widget.loginDateTime; @@ -84,11 +79,13 @@ class _TravelPartnerPageState extends State _pendingGrant.contains(characterId)); Future _fetchData() async { - if (!TitleServerConfigHolder().isConfigured) return; - setState(() => _loading = true); + final service = SessionModel.instance.createTitleService( + cookies: widget.cookies, + ); + if (service == null) return; + setRiskFetching(true); try { - final service = TitleApiService.fromHolder(cookies: widget.cookies)!; final results = await Future.wait([ service.getUserCharacters(widget.userId), service.fetchUserAllData(widget.userId), @@ -109,12 +106,12 @@ class _TravelPartnerPageState extends State _userAllData = all; _slots = normalizeCharaSlot(slots); // 槽位里引用了但列表没有的角色,保留为可选,避免下拉框丢项。 - _loading = false; + riskFetching = false; }); } catch (e) { if (!mounted) return; - setState(() => _loading = false); - _snack('${AppStrings.loadFailed}: $e'); + setState(() => riskFetching = false); + _snack('${AppStrings.loadFailed}: ${describeApiError(e)}'); } } @@ -192,18 +189,15 @@ class _TravelPartnerPageState extends State } Future _run() async { - if (!TitleServerConfigHolder().isConfigured) { - _snack(AppStrings.ticketNotConfigured); - return; - } - final loginDateTime = widget.loginDateTime; - final loginId = widget.loginId; - if (loginDateTime == null || loginId == null) { - _snack(AppStrings.travelPartnerNotLoggedIn); - return; - } - if (cooldownRemaining > 0) { - _snack(AppStrings.travelPartnerCooldownNotice(cooldownRemaining)); + final guard = riskPreconditionError( + loginDateTime: widget.loginDateTime, + loginId: widget.loginId, + cooldownRemaining: cooldownRemaining, + notLoggedInMessage: AppStrings.travelPartnerNotLoggedIn, + cooldownNotice: AppStrings.travelPartnerCooldownNotice, + ); + if (guard != null) { + _snack(guard); return; } final validationError = _validate(); @@ -212,20 +206,25 @@ class _TravelPartnerPageState extends State return; } - setState(() => _running = true); + final loginDateTime = widget.loginDateTime!; + final loginId = widget.loginId!; + setRiskRunning(true); + clearRiskError(); try { - final service = TitleApiService.fromHolder(cookies: widget.cookies)!; + final service = SessionModel.instance.createTitleService( + cookies: widget.cookies, + )!; final builder = UserAllPayloadBuilder(service.config); var data = _userAllData; if (data.isEmpty) { - _updateStep(RiskStep.fetchData); + updateRiskStep(RiskStep.fetchData); data = await service.fetchUserAllData(widget.userId); if (mounted) setState(() => _userAllData = data); } - _updateStep(RiskStep.upload); + updateRiskStep(RiskStep.upload); final packet = builder.build( userId: widget.userId, loginId: loginId, @@ -273,25 +272,17 @@ class _TravelPartnerPageState extends State // 两种失败在返回值上完全一样(returnCode 都是 1),只能靠回读区分: // 服务器没写入 userCharacterList,还是写入了但 ID 不在机台 Chara 表里 // 被客户端 CharacterSelectProces 静默跳过。 - _updateStep( + updateRiskStep( RiskStep.complete, _pendingGrant.isEmpty ? AppStrings.travelPartnerSuccess : await _verifyGranted(service), ); - - if (_autoLogout && widget.onExitToTitle != null) { - await Future.delayed(const Duration(seconds: 2)); - if (!mounted) return; - _updateStep(RiskStep.logout, AppStrings.exitingToTitle); - await widget.onExitToTitle!(); - } - } on TitleApiException catch (e) { - _updateStep(RiskStep.failed, e.message); + await autoLogoutAndExit(widget.onExitToTitle); } catch (e) { - _updateStep(RiskStep.failed, '$e'); + reportRiskError(e); } finally { - if (mounted) setState(() => _running = false); + setRiskRunning(false); } } @@ -301,7 +292,7 @@ class _TravelPartnerPageState extends State try { owned = await service.getUserCharacters(widget.userId); } catch (e) { - return AppStrings.travelPartnerVerifyFailed('$e'); + return AppStrings.travelPartnerVerifyFailed(describeApiError(e)); } if (mounted) setState(() => _owned = owned); @@ -311,14 +302,6 @@ class _TravelPartnerPageState extends State return AppStrings.travelPartnerNotSaved(missing.join(' / ')); } - void _updateStep(RiskStep step, [String? message]) { - if (!mounted) return; - setState(() { - _step = step; - _stepMessage = message ?? ''; - }); - } - @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -327,211 +310,146 @@ class _TravelPartnerPageState extends State appBar: AppBar( title: const Text(AppStrings.travelPartnerFeatureTitle), ), - body: SingleChildScrollView( - padding: const EdgeInsets.all(16), - child: responsiveBody( - context, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (!isLoggedIn) - const AppNotice( - AppStrings.travelPartnerNotLoggedIn, - error: true, - ), - if (isLoggedIn && cooldownRemaining > 0) - AppNotice( - AppStrings.travelPartnerCooldownNotice(cooldownRemaining), - ), - _sectionCard( - theme, - icon: Icons.card_giftcard, - title: AppStrings.travelPartnerFeatureDesc, - ), - const SizedBox(height: 12), - _fetchCard(theme), - const SizedBox(height: 12), - _grantCard(theme), - const SizedBox(height: 12), - _slotCard(theme), - const SizedBox(height: 12), - _ownedCard(theme), - const SizedBox(height: 12), - AutoLogoutToggle( - value: _autoLogout, - enabled: widget.onExitToTitle != null && !_running, - onChanged: (v) => setState(() => _autoLogout = v), - ), - const SizedBox(height: 12), - _runButton(theme), - if (_step != RiskStep.idle) ...[ - const SizedBox(height: 16), - StepProgressCard( - step: _step, - message: _stepMessage, - steps: [ - ( - RiskStep.fetchData, - AppStrings.travelPartnerStepFetch, - ), - ( - RiskStep.upload, - AppStrings.travelPartnerStepUpload, - ), - if (_autoLogout) (RiskStep.logout, AppStrings.stepLogout), - ], - ), + body: RiskPageBody( + children: [ + RiskStatusNotices( + isLoggedIn: isLoggedIn, + cooldownRemaining: cooldownRemaining, + notLoggedInText: AppStrings.travelPartnerNotLoggedIn, + cooldownText: AppStrings.travelPartnerCooldownNotice, + ), + RiskFeatureCard( + icon: Icons.card_giftcard, + title: AppStrings.travelPartnerFeatureDesc, + ), + const SizedBox(height: 12), + RiskFetchCard( + hasData: _userAllData.isNotEmpty && _owned.isNotEmpty, + statusText: _userAllData.isNotEmpty && _owned.isNotEmpty + ? AppStrings.travelPartnerFetched + : AppStrings.travelPartnerNoData, + loadingLabel: AppStrings.travelPartnerFetching, + actionLabel: _userAllData.isNotEmpty + ? AppStrings.travelPartnerRefetch + : AppStrings.travelPartnerFetchData, + loading: riskFetching, + enabled: !riskRunning, + onFetch: _fetchData, + ), + const SizedBox(height: 12), + _grantCard(theme), + const SizedBox(height: 12), + _slotCard(theme), + const SizedBox(height: 12), + _ownedCard(theme), + const SizedBox(height: 12), + AutoLogoutToggle( + value: riskAutoLogout, + enabled: widget.onExitToTitle != null && !riskRunning, + onChanged: setRiskAutoLogout, + ), + const SizedBox(height: 12), + RiskRunButton( + running: riskRunning, + enabled: isLoggedIn, + icon: Icons.send, + label: AppStrings.travelPartnerRun, + runningLabel: AppStrings.travelPartnerRunning, + onPressed: _run, + ), + if (riskStep != RiskStep.idle) ...[ + const SizedBox(height: 16), + StepProgressCard( + step: riskStep, + message: riskStepMessage, + error: riskError, + steps: [ + (RiskStep.fetchData, AppStrings.travelPartnerStepFetch), + (RiskStep.upload, AppStrings.travelPartnerStepUpload), + if (riskAutoLogout) (RiskStep.logout, AppStrings.stepLogout), ], - ], - ), - ), - ), - ); - } - - Widget _sectionCard( - ThemeData theme, { - required IconData icon, - required String title, - List children = const [], - }) { - return AppCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SectionTitle(icon: icon, title: title), - ...children, - ], - ), - ); - } - - Widget _fetchCard(ThemeData theme) { - final hasData = _userAllData.isNotEmpty && _owned.isNotEmpty; - return AppCard( - child: Row( - children: [ - Icon( - hasData ? Icons.check_circle : Icons.download, - size: 18, - color: hasData ? Colors.green : theme.colorScheme.primary, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - hasData - ? AppStrings.travelPartnerFetched - : AppStrings.travelPartnerNoData, - style: theme.textTheme.bodySmall?.copyWith( - color: hasData ? Colors.green : theme.colorScheme.onSurfaceVariant, - ), ), - ), - SizedBox( - height: 32, - child: OutlinedButton.icon( - onPressed: _loading || _running ? null : _fetchData, - icon: _loading - ? const SizedBox( - width: 14, - height: 14, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.refresh, size: 16), - label: Text( - _loading - ? AppStrings.travelPartnerFetching - : _userAllData.isNotEmpty - ? AppStrings.travelPartnerRefetch - : AppStrings.travelPartnerFetchData, - style: theme.textTheme.labelSmall, - ), - style: OutlinedButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 12), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - ), - ), + ], ], ), ); } Widget _grantCard(ThemeData theme) { - final enabled = !_running; - return _sectionCard( - theme, + final enabled = !riskRunning; + return RiskFeatureCard( icon: Icons.add_circle_outline, title: AppStrings.travelPartnerGrantTitle, - children: [ - const SizedBox(height: 14), - TextField( - controller: _grantIdController, - enabled: enabled, - keyboardType: TextInputType.number, - decoration: InputDecoration( - labelText: AppStrings.travelPartnerCharacterIdLabel, - hintText: AppStrings.travelPartnerCharacterIdHint, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - ), - contentPadding: const EdgeInsets.all(14), - ), - ), - const SizedBox(height: 8), - SizedBox( - width: double.infinity, - child: OutlinedButton.icon( - onPressed: enabled ? _addGrant : null, - icon: const Icon(Icons.add, size: 18), - label: const Text(AppStrings.listAddButton), - style: OutlinedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 12), - shape: RoundedRectangleBorder( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 14), + TextField( + controller: _grantIdController, + enabled: enabled, + keyboardType: TextInputType.number, + decoration: InputDecoration( + labelText: AppStrings.travelPartnerCharacterIdLabel, + hintText: AppStrings.travelPartnerCharacterIdHint, + border: OutlineInputBorder( borderRadius: BorderRadius.circular(10), ), + contentPadding: const EdgeInsets.all(14), + ), + ), + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: enabled ? _addGrant : null, + icon: const Icon(Icons.add, size: 18), + label: const Text(AppStrings.listAddButton), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), ), ), - ), - const SizedBox(height: 10), - Text( - _pendingGrant.isEmpty - ? AppStrings.travelPartnerGrantEmpty - : _pendingGrant.map((e) => '#$e').join(' '), - style: theme.textTheme.bodySmall?.copyWith( - fontFamily: 'monospace', - color: _pendingGrant.isEmpty - ? theme.colorScheme.onSurfaceVariant - : theme.colorScheme.onSurface, - ), - ), - const SizedBox(height: 12), - Text( - AppStrings.travelPartnerIdRangeTitle, - style: theme.textTheme.labelMedium?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 4), - Text( - AppStrings.travelPartnerIdRangeBody, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - height: 1.5, - ), - ), - if (_pendingGrant.any(_isSuspectId)) ...[ - const SizedBox(height: 8), + const SizedBox(height: 10), Text( - AppStrings.travelPartnerIdUnknown, + _pendingGrant.isEmpty + ? AppStrings.travelPartnerGrantEmpty + : _pendingGrant.map((e) => '#$e').join(' '), style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.error, + fontFamily: 'monospace', + color: _pendingGrant.isEmpty + ? theme.colorScheme.onSurfaceVariant + : theme.colorScheme.onSurface, ), ), + const SizedBox(height: 12), + Text( + AppStrings.travelPartnerIdRangeTitle, + style: theme.textTheme.labelMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Text( + AppStrings.travelPartnerIdRangeBody, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + height: 1.5, + ), + ), + if (_pendingGrant.any(_isSuspectId)) ...[ + const SizedBox(height: 8), + Text( + AppStrings.travelPartnerIdUnknown, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.error, + ), + ), + ], ], - ], + ), ); } @@ -558,60 +476,23 @@ class _TravelPartnerPageState extends State }.toList() ..sort(); - return _sectionCard( - theme, + return RiskFeatureCard( icon: Icons.groups_outlined, title: AppStrings.travelPartnerSlotTitle, - children: [ - const SizedBox(height: 14), - for (var i = 0; i < 5; i++) - Padding( - padding: const EdgeInsets.only(bottom: 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - DropdownButtonFormField( - initialValue: _slots[i], - decoration: InputDecoration( - labelText: AppStrings.travelPartnerSlotLabel(i), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 14, - vertical: 12, - ), - ), - items: [ - DropdownMenuItem( - value: 0, - child: Text( - AppStrings.travelPartnerSlotNone, - style: theme.textTheme.bodyMedium, - ), - ), - for (final id in optionIds) - DropdownMenuItem(value: id, child: Text('#$id')), - ], - onChanged: _running - ? null - : (v) => setState(() { - final next = [..._slots]; - next[i] = v ?? 0; - _slots = next; - }), - ), - if (_slots[i] != 0) ...[ - const SizedBox(height: 6), - TextField( - controller: _slotLevelControllers[i], - enabled: !_running, - keyboardType: TextInputType.number, - onChanged: (_) => setState(() {}), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 14), + for (var i = 0; i < 5; i++) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + DropdownButtonFormField( + initialValue: _slots[i], decoration: InputDecoration( - isDense: true, - labelText: AppStrings.travelPartnerLevelLabel, - hintText: AppStrings.travelPartnerLevelHint, + labelText: AppStrings.travelPartnerSlotLabel(i), border: OutlineInputBorder( borderRadius: BorderRadius.circular(10), ), @@ -620,27 +501,66 @@ class _TravelPartnerPageState extends State vertical: 12, ), ), + items: [ + DropdownMenuItem( + value: 0, + child: Text( + AppStrings.travelPartnerSlotNone, + style: theme.textTheme.bodyMedium, + ), + ), + for (final id in optionIds) + DropdownMenuItem(value: id, child: Text('#$id')), + ], + onChanged: riskRunning + ? null + : (v) => setState(() { + final next = [..._slots]; + next[i] = v ?? 0; + _slots = next; + }), ), - ..._levelHintLines(theme, _slotLevelControllers[i].text), + if (_slots[i] != 0) ...[ + const SizedBox(height: 6), + TextField( + controller: _slotLevelControllers[i], + enabled: !riskRunning, + keyboardType: TextInputType.number, + onChanged: (_) => setState(() {}), + decoration: InputDecoration( + isDense: true, + labelText: AppStrings.travelPartnerLevelLabel, + hintText: AppStrings.travelPartnerLevelHint, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 12, + ), + ), + ), + ..._levelHintLines(theme, _slotLevelControllers[i].text), + ], ], - ], + ), ), - ), - SizedBox( - width: double.infinity, - child: OutlinedButton.icon( - onPressed: _running ? null : _copySlot0ToOthers, - icon: const Icon(Icons.copy_all_outlined, size: 18), - label: const Text(AppStrings.travelPartnerCopySlot0), - style: OutlinedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 12), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: riskRunning ? null : _copySlot0ToOthers, + icon: const Icon(Icons.copy_all_outlined, size: 18), + label: const Text(AppStrings.travelPartnerCopySlot0), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), ), ), ), - ), - ], + ], + ), ); } @@ -664,70 +584,46 @@ class _TravelPartnerPageState extends State } Widget _ownedCard(ThemeData theme) { - return _sectionCard( - theme, + return RiskFeatureCard( icon: Icons.workspace_premium_outlined, - title: - '${AppStrings.travelPartnerOwnedTitle} (${_owned.length})', - children: [ - const SizedBox(height: 14), - if (_owned.isEmpty) - Text( - AppStrings.travelPartnerOwnedEmpty, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ) - else - Wrap( - spacing: 8, - runSpacing: 6, - children: [ - for (final c in _owned) - Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 4, - ), - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest - .withValues(alpha: 0.5), - borderRadius: BorderRadius.circular(6), - ), - child: Text( - '#${c.characterId} Lv${c.level}', - style: theme.textTheme.labelSmall?.copyWith( - fontFamily: 'monospace', + title: '${AppStrings.travelPartnerOwnedTitle} (${_owned.length})', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 14), + if (_owned.isEmpty) + Text( + AppStrings.travelPartnerOwnedEmpty, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ) + else + Wrap( + spacing: 8, + runSpacing: 6, + children: [ + for (final c in _owned) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest + .withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + '#${c.characterId} Lv${c.level}', + style: theme.textTheme.labelSmall?.copyWith( + fontFamily: 'monospace', + ), ), ), - ), - ], - ), - ], - ); - } - - Widget _runButton(ThemeData theme) { - return SizedBox( - width: double.infinity, - child: FilledButton.icon( - onPressed: _running || !isLoggedIn ? null : _run, - icon: _running - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.send, size: 20), - label: Text( - _running ? AppStrings.travelPartnerRunning : AppStrings.travelPartnerRun, - ), - style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 14), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), + ], + ), + ], ), ); } diff --git a/lib/services/account_backup_service.dart b/lib/services/account_backup_service.dart index 726e9c1..3951118 100644 --- a/lib/services/account_backup_service.dart +++ b/lib/services/account_backup_service.dart @@ -64,7 +64,7 @@ class AccountBackupService { final value = await read(); sections[name] = value; } catch (e) { - failed.add('$name: $e'); + failed.add('$name: ${describeApiError(e)}'); } } @@ -103,7 +103,7 @@ class AccountBackupService { try { items['$kind'] = await _api.getUserItems(userId, kind); } catch (e) { - failed.add('userItemList/$kind: $e'); + failed.add('userItemList/$kind: ${describeApiError(e)}'); } }(), ]); diff --git a/lib/services/aimedb_transport.dart b/lib/services/aimedb_transport.dart new file mode 100644 index 0000000..1bab10f --- /dev/null +++ b/lib/services/aimedb_transport.dart @@ -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 post( + Uri uri, { + required Map headers, + required List 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 _dartPost( + Uri uri, + Map headers, + List 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 _winHttpPost( + Uri uri, + Map headers, + List 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, + Uint32, + Pointer, + Pointer, + Uint32, +); +typedef _OpenDart = int Function( + Pointer, + int, + Pointer, + Pointer, + int, +); + +typedef _ConnectNative = IntPtr Function( + IntPtr, + Pointer, + Uint16, + Uint32, +); +typedef _ConnectDart = int Function(int, Pointer, int, int); + +typedef _OpenRequestNative = IntPtr Function( + IntPtr, + Pointer, + Pointer, + Pointer, + Pointer, + Pointer, + Uint32, +); +typedef _OpenRequestDart = int Function( + int, + Pointer, + Pointer, + Pointer, + Pointer, + Pointer, + 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, + Uint32, + Pointer, + Uint32, + Uint32, + IntPtr, +); +typedef _SendDart = int Function( + int, + Pointer, + int, + Pointer, + int, + int, + int, +); + +typedef _ReceiveNative = Int32 Function(IntPtr, Pointer); +typedef _ReceiveDart = int Function(int, Pointer); + +typedef _QueryHeadersNative = Int32 Function( + IntPtr, + Uint32, + Pointer, + Pointer, + Pointer, + Pointer, +); +typedef _QueryHeadersDart = int Function( + int, + int, + Pointer, + Pointer, + Pointer, + Pointer, +); + +typedef _ReadNative = Int32 Function( + IntPtr, + Pointer, + Uint32, + Pointer, +); +typedef _ReadDart = int Function(int, Pointer, int, Pointer); + +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 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(body.isEmpty ? 1 : body.length); + if (body.isNotEmpty) { + bodyPtr.asTypedList(body.length).setAll(0, body); + } + final statusPtr = calloc(); + final statusLen = calloc()..value = 4; + final readBuf = calloc(16384); + final readLen = calloc(); + + 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)'); +} diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index b34e043..5e707d2 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -1,9 +1,9 @@ import 'dart:convert'; import 'package:crypto/crypto.dart'; -import 'package:http/http.dart' as http; import '../config/title_server_config.dart'; +import 'aimedb_transport.dart'; import 'api_log.dart'; class LoginResult { @@ -12,90 +12,171 @@ class LoginResult { final int userId; final String token; + /// 服务器拒绝登录时给用户的说明(含 errorID 的含义)。 + final String? message; + const LoginResult({ required this.success, required this.errorId, required this.userId, required this.token, + this.message, }); } +/// aimedb 扫码登录:二维码串(或二维码链接)→ userID + token。 +/// +/// 与 Astral `api/Chime.py` 保持一致: +/// * 签名 key = SHA-256(完整 Keychip + 本机本地时间 + 签名盐),大写 hex; +/// 拼接里不含 qrCode,也不含 titlekey。 +/// * `qrCode` 字段发 84 串第 21 位起的 64 位载荷,不是整串。 +/// * 请求头**不能带 Content-Type**,带了服务器直接回 errorID=57。 +/// * chipID 用完整 Keychip(带连字符);游戏服务器那边才用 11 位短值。 class ApiService { + static const String _aimeHost = 'ai.sys-allnet.cn'; + static const String _aimePath = '/wc_aime/api/get_data'; + static const String _signKey = 'XcW5FW4cPArBXEk4vzKz3CIrMuA5EVVW'; + static const String _openGameId = 'MAID'; + static const String _titleKey = 'SDGB'; + static const String _userAgent = 'WC_AIME_LIB'; + + static const String _qrPrefix = 'SGWC'; + static const String _qrGameId = 'MAID'; + + /// SGWC(4) + MAID(4) + 时间戳(12) + static const int _qrHeadLength = 20; + static const int _qrTotalLength = 84; + + static final Uri _aimeUri = Uri.https(_aimeHost, _aimePath); + final TitleServerConfig? _config; ApiService([this._config]); TitleServerConfig? get _cfg => _config ?? TitleServerConfigHolder().config; - String get _chipId => _cfg?.keychipId ?? ''; - String get _aimeSalt => _cfg?.aimeSalt ?? ''; - String get _aimeUrl => _cfg?.aimeUrl ?? ''; - String get _openGameID { - final v = _cfg?.openGameID; - return (v != null && v.isNotEmpty) ? v : 'MAID'; - } - String _formatTimestamp() { - final tokyo = DateTime.now().toUtc().add(const Duration(hours: 9)); - final y = (tokyo.year % 100).toString().padLeft(2, '0'); - final M = tokyo.month.toString().padLeft(2, '0'); - final d = tokyo.day.toString().padLeft(2, '0'); - final h = tokyo.hour.toString().padLeft(2, '0'); - final m = tokyo.minute.toString().padLeft(2, '0'); - final s = tokyo.second.toString().padLeft(2, '0'); - return '$y$M$d$h$m$s'; - } + /// 任意输入 -> 标准 84 字符二维码串,格式不合预期时抛 [FormatException]。 + static String normalizeQr(String input) { + var qr = input.trim(); - String _sha256(String input) { - final bytes = utf8.encode(input); - return sha256.convert(bytes).toString(); - } - - static String extractQRCode(String qrCodeToken) { - if (qrCodeToken.length > 64) { - return qrCodeToken.substring(qrCodeToken.length - 64); + if (qr.startsWith('http://') || qr.startsWith('https://')) { + final path = Uri.parse(qr).path; + final match = RegExp(r'/qrcode/req/([^/]+)\.html').firstMatch(path); + if (match == null) { + throw FormatException('这个链接里取不出二维码数据:$qr'); + } + qr = _qrPrefix + match.group(1)!; } - return qrCodeToken; + + if (qr.length != _qrTotalLength) { + throw FormatException( + '二维码长度应为 $_qrTotalLength 字符,实际 ${qr.length} 字符', + ); + } + if (!qr.startsWith(_qrPrefix)) { + throw FormatException('二维码应当以 "$_qrPrefix" 开头'); + } + if (qr.substring(4, 8) != _qrGameId) { + throw FormatException('二维码里的 GameID 应当是 "$_qrGameId"'); + } + final payload = qr.substring(_qrHeadLength); + if (!RegExp(r'^[0-9A-F]+$').hasMatch(payload)) { + throw FormatException('二维码载荷必须是大写十六进制'); + } + return qr; } - Future login(String qrCodeToken) async { - final timestamp = _formatTimestamp(); - final qrCode = extractQRCode(qrCodeToken); - final chipId = _chipId; - final rawKey = chipId + timestamp + _aimeSalt; - final key = _sha256(rawKey).toUpperCase(); + /// 请求体 `qrCode` 字段的值:84 串去掉前 20 位后的 64 位载荷。 + static String extractQRCode(String input) => + normalizeQr(input).substring(_qrHeadLength); - final body = jsonEncode({ - 'chipID': chipId, - 'openGameID': _openGameID, - 'key': key, - 'qrCode': qrCode, - 'timestamp': timestamp, - }); + /// 真机报的是机台本地墙上时间,所以这里跟着设备时区走,不固定偏移。 + static String formatTimestamp(DateTime now) { + String two(int v) => v.toString().padLeft(2, '0'); + return '${two(now.year % 100)}${two(now.month)}${two(now.day)}' + '${two(now.hour)}${two(now.minute)}${two(now.second)}'; + } - ApiLog.log('[login] POST $_aimeUrl'); - ApiLog.log('[login] chipId=$chipId openGameID=$_openGameID'); + Future login(String qrInput) async { + final chipId = _cfg?.fullKeychipId ?? ''; + if (chipId.isEmpty) { + throw const FormatException('设置里没有 Keychip,无法生成签名'); + } - final response = await http - .post( - Uri.parse(_aimeUrl), - headers: { - 'Content-Type': 'application/json', - 'User-Agent': 'WC_AIME_LIB', - }, - body: body, - ) - .timeout(const Duration(seconds: 15)); + final qr = normalizeQr(qrInput); + final timestamp = formatTimestamp(DateTime.now()); + final key = sha256 + .convert(utf8.encode(chipId + timestamp + _signKey)) + .toString() + .toUpperCase(); - final obj = jsonDecode(response.body) as Map; - final errorId = obj['errorID'] as int? ?? -1; - final userId = obj['userID'] as int? ?? -1; + final bodyBytes = utf8.encode( + jsonEncode({ + 'chipID': chipId, + 'openGameID': _openGameId, + 'key': key, + 'qrCode': qr.substring(_qrHeadLength), + 'timestamp': timestamp, + 'titlekey': _titleKey, + }), + ); + + ApiLog.log('[login] POST https://$_aimeHost$_aimePath'); + ApiLog.log('[login] chipID=$chipId timestamp=$timestamp'); + + // 不带 Content-Type:aimedb 见到这个头会回 errorID=57。 + // 传输层在 Windows 上走 WinHTTP,绕过服务器对 BoringSSL 指纹的拦截。 + final response = await AimedbTransport.post( + _aimeUri, + headers: const {'Connection': 'Keep-Alive', 'User-Agent': _userAgent}, + body: bodyBytes, + ).timeout(const Duration(seconds: 25)); + + ApiLog.log( + '[login] HTTP ${response.statusCode}, ${response.bodyBytes.length} bytes', + ); + + final Map obj; + try { + obj = jsonDecode(response.body) as Map; + } on Exception { + final preview = response.body.trim(); + throw FormatException( + 'aimedb 的响应不是合法 JSON:' + '${preview.length > 200 ? preview.substring(0, 200) : preview}', + ); + } + + final errorId = (obj['errorID'] as num?)?.toInt() ?? -1; + final userId = (obj['userID'] as num?)?.toInt() ?? -1; final token = obj['token'] as String? ?? ''; - // Session cookies (JSESSIONID) are captured from the title server's - // UserLoginApi response, not from the Aime server — see empurple. + if (errorId != 0) { + return LoginResult( + success: false, + errorId: errorId, + userId: userId, + token: token, + message: errorId == 57 + ? '登录被拒(errorID=57):二维码已失效或已被使用' + : '登录被拒(errorID=$errorId)', + ); + } + if (userId == -1 || token.isEmpty) { + return LoginResult( + success: false, + errorId: errorId, + userId: userId, + token: token, + message: '扫码登录没有拿到 userID/token' + '(userID=$userId,token 长度 ${token.length})', + ); + } + final tokenHead = token.length < 8 ? token : token.substring(0, 8); + ApiLog.log('[login] 成功 userID=$userId token=$tokenHead...'); return LoginResult( - success: errorId == 0, + success: true, errorId: errorId, userId: userId, token: token, diff --git a/lib/services/music_data_service.dart b/lib/services/music_data_service.dart index b7489da..ba6ae49 100644 --- a/lib/services/music_data_service.dart +++ b/lib/services/music_data_service.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'package:http/http.dart' as http; +import '../core/json_value.dart'; import 'music_data_cache.dart'; class MusicInfo { @@ -62,32 +63,18 @@ class MusicDataService { if (decoded is! List) return; _byId.clear(); for (final item in decoded.whereType>()) { - final id = _toInt(item['id']); + final id = jsonInt(item['id']); if (id == 0) continue; final ds = item['ds']; _byId[id] = MusicInfo( - title: item['title']?.toString() ?? 'Unknown', + title: jsonString(item['title'], fallback: 'Unknown'), dsByLevel: ds is List ? { for (var level = 0; level < ds.length; level++) - level: _toDouble(ds[level]), + level: jsonDouble(ds[level]), } : const {}, ); } } - - static int _toInt(dynamic v) { - if (v is int) return v; - if (v is num) return v.toInt(); - if (v is String) return int.tryParse(v) ?? 0; - return 0; - } - - static double _toDouble(dynamic v) { - if (v is double) return v; - if (v is int) return v.toDouble(); - if (v is String) return double.tryParse(v) ?? 0.0; - return 0.0; - } } diff --git a/lib/services/title_api_service.dart b/lib/services/title_api_service.dart index d743494..15a42da 100644 --- a/lib/services/title_api_service.dart +++ b/lib/services/title_api_service.dart @@ -8,6 +8,7 @@ import 'package:http/http.dart' as http; import 'package:pointycastle/export.dart'; import '../config/title_server_config.dart'; +import '../core/date_time_format.dart'; import '../models/user_data.dart'; import 'api_log.dart'; import '../models/user_character.dart'; @@ -22,6 +23,12 @@ class TitleApiException implements Exception { String toString() => 'TitleApiException: $message'; } +/// 面向用户的错误文本:协议层异常只取 message,其余保留 `toString()` 便于排查。 +String describeApiError(Object error) => + error is TitleApiException ? error.message : error.toString(); + +typedef CookiesChangedCallback = void Function(String cookies); + class UserLoginResult { final String token; final int loginId; @@ -39,21 +46,37 @@ class UserLoginResult { class TitleApiService { static const String _obfuscateConstant = 'MaimaiChn'; - static String? lastRawResponse; + /// 最近一次 `captureRaw` 请求的响应原文(主页调试卡用)。 + String? lastRawResponse; final TitleServerConfig _config; + final CookiesChangedCallback? onCookiesChanged; String? _cookies; - TitleApiService(this._config, {this._cookies}); + // 私有命名参数不合法(不能写 `this._cookies`),只能手写初始化。 + TitleApiService( + this._config, { + String? cookies, + this.onCookiesChanged, + }) : + // ignore: prefer_initializing_formals + _cookies = cookies; /// 从 [TitleServerConfigHolder] 构造;未配置时返回 `null`。 /// /// 各页原本都写 `if (!holder.isConfigured) ...; TitleApiService(holder.config!, ...)`, /// 收口成一个工厂,避免 `config!` 与 `isConfigured` 散落在十几个地方。 - static TitleApiService? fromHolder({String? cookies}) { + static TitleApiService? fromHolder({ + String? cookies, + CookiesChangedCallback? onCookiesChanged, + }) { final holder = TitleServerConfigHolder(); if (!holder.isConfigured) return null; - return TitleApiService(holder.config!, cookies: cookies); + return TitleApiService( + holder.config!, + cookies: cookies, + onCookiesChanged: onCookiesChanged, + ); } String? get cookies => _cookies; @@ -122,6 +145,7 @@ class TitleApiService { if (match != null) { _cookies = 'JSESSIONID=${match.group(1)!.trim()}'; ApiLog.log('[$apiName] >>> Captured session cookie: $_cookies'); + onCookiesChanged?.call(_cookies!); return; } } @@ -243,8 +267,8 @@ class TitleApiService { required int userId, required String token, }) async { - // 走通用传输层,保留 `lastRawResponse`(主页调试卡要用 GetUserPreviewApi - // 的原文,而不是随后 UserLoginApi / GetUserDataApi 的)。 + // 走通用传输层,把 GetUserPreviewApi 的原文留在实例上(主页调试卡要用它, + // 而不是随后 UserLoginApi / GetUserDataApi 的响应)。 final json = await _callApi( 'GetUserPreviewApi', { @@ -386,10 +410,8 @@ class TitleApiService { final now = DateTime.now(); final validDate = DateTime(now.year, now.month, now.day, 4, 0, 0) .add(const Duration(days: 90)); - final purchaseDateStr = _formatDateTime(now); - final validDateStr = - '${validDate.year}-${validDate.month.toString().padLeft(2, '0')}-${validDate.day.toString().padLeft(2, '0')} ' - '${validDate.hour.toString().padLeft(2, '0')}:${validDate.minute.toString().padLeft(2, '0')}:${validDate.second.toString().padLeft(2, '0')}'; + final purchaseDateStr = formatPlayDateTime(now); + final validDateStr = formatDateTime(validDate); final packet = { 'userId': userId, @@ -416,11 +438,6 @@ class TitleApiService { _checkReturnCode(json, apiName); } - String _formatDateTime(DateTime dt) { - return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')} ' - '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}:${dt.second.toString().padLeft(2, '0')}.0'; - } - // ---- UpsertUserAll (发包) ---- /// Upload a single playlog record (fake game log). diff --git a/lib/services/user_all_payload_builder.dart b/lib/services/user_all_payload_builder.dart index 0383a0e..7304270 100644 --- a/lib/services/user_all_payload_builder.dart +++ b/lib/services/user_all_payload_builder.dart @@ -1,4 +1,6 @@ import '../config/title_server_config.dart'; +import '../core/date_time_format.dart'; +import '../core/json_value.dart'; import '../models/user_character.dart'; import 'title_api_service.dart'; @@ -43,28 +45,12 @@ class UserAllPayloadBuilder { const UserAllPayloadBuilder(this.config); - static DateTime _shanghaiNow() => - DateTime.now().toUtc().add(const Duration(hours: 8)); - - static String _two(int v) => v.toString().padLeft(2, '0'); - - static String _formatPlayDate(DateTime dt) => - '${dt.year}-${_two(dt.month)}-${_two(dt.day)}'; - - static String _formatPlayDateTime(DateTime dt) => - '${dt.year}-${_two(dt.month)}-${_two(dt.day)} ' - '${_two(dt.hour)}:${_two(dt.minute)}:${_two(dt.second)}.0'; - /// 客户端 `TimeManager.GetDateString` 的格式是 /// `ToString("yyyy-MM-dd HH:mm:ss.f")`,也就是带一位小数的秒。 /// 万花筒的 `clearDate` 之类字段要按这个形状给。 - static String nowTimestamp() => _formatPlayDateTime(_shanghaiNow()); + static String nowTimestamp() => formatPlayDateTime(shanghaiNow()); - static int _toInt(dynamic v) { - if (v is int) return v; - if (v is num) return v.toInt(); - return 0; - } + static int _toInt(dynamic v) => jsonInt(v); static int clampValue(int v) => v < minValue ? minValue : (v > maxValue ? maxValue : v); @@ -122,11 +108,10 @@ class UserAllPayloadBuilder { (i) => i < charaSlot.length ? charaSlot[i] : 0, ); - final now = _shanghaiNow(); - final playDate = _formatPlayDate(now); - final playDateTime = _formatPlayDateTime(now); - final loginDate = - _formatPlayDateTime(DateTime.fromMillisecondsSinceEpoch(loginDateTime * 1000).toUtc().add(const Duration(hours: 8))); + final now = shanghaiNow(); + final playDate = formatDate(now); + final playDateTime = formatPlayDateTime(now); + final loginDate = formatPlayDateTime(shanghaiFromUnixSeconds(loginDateTime)); // Lionheart: GetUserDataApi 返回的部分字段不能出现在 UpsertUserAll 的 // userData 中,否则服务器会静默丢弃整包处理。 diff --git a/lib/widgets/account_backup_card.dart b/lib/widgets/account_backup_card.dart index 1bd8e17..234241a 100644 --- a/lib/widgets/account_backup_card.dart +++ b/lib/widgets/account_backup_card.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart' show Clipboard, ClipboardData; import '../config/strings.dart'; +import '../models/session_model.dart'; import '../services/account_backup_service.dart'; import '../services/title_api_service.dart'; import 'app_card.dart'; @@ -28,7 +29,9 @@ class _AccountBackupCardState extends State { String? _error; Future _run() async { - final service = TitleApiService.fromHolder(cookies: widget.cookies); + final service = SessionModel.instance.createTitleService( + cookies: widget.cookies, + ); if (service == null) { context.showSnack(AppStrings.ticketNotConfigured); return; @@ -56,7 +59,7 @@ class _AccountBackupCardState extends State { } catch (e) { if (!mounted) return; setState(() { - _error = '$e'; + _error = describeApiError(e); _running = false; }); } diff --git a/lib/widgets/app_card.dart b/lib/widgets/app_card.dart index 44b7c49..fc37452 100644 --- a/lib/widgets/app_card.dart +++ b/lib/widgets/app_card.dart @@ -11,6 +11,7 @@ class AppCard extends StatelessWidget { final EdgeInsetsGeometry padding; final Color? borderColor; final EdgeInsetsGeometry? margin; + final VoidCallback? onTap; const AppCard({ super.key, @@ -18,11 +19,14 @@ class AppCard extends StatelessWidget { this.padding = const EdgeInsets.all(20), this.borderColor, this.margin, + this.onTap, }); @override Widget build(BuildContext context) { final theme = Theme.of(context); + final content = Padding(padding: padding, child: child); + return Card( elevation: 0, margin: margin, @@ -33,7 +37,13 @@ class AppCard extends StatelessWidget { theme.colorScheme.outline.withValues(alpha: 0.3), ), ), - child: Padding(padding: padding, child: child), + child: onTap == null + ? content + : InkWell( + borderRadius: BorderRadius.circular(16), + onTap: onTap, + child: content, + ), ); } } diff --git a/lib/widgets/home/home_common.dart b/lib/widgets/home/home_common.dart new file mode 100644 index 0000000..aa109b4 --- /dev/null +++ b/lib/widgets/home/home_common.dart @@ -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 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), + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/home/home_debug_card.dart b/lib/widgets/home/home_debug_card.dart new file mode 100644 index 0000000..e16136d --- /dev/null +++ b/lib/widgets/home/home_debug_card.dart @@ -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 createState() => _DebugRawJsonCardState(); +} + +class _DebugRawJsonCardState extends State { + 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, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/home/home_preview_cards.dart b/lib/widgets/home/home_preview_cards.dart new file mode 100644 index 0000000..70b6ac8 --- /dev/null +++ b/lib/widgets/home/home_preview_cards.dart @@ -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})', + ), + ], + ); + } +} diff --git a/lib/widgets/home/home_user_data_cards.dart b/lib/widgets/home/home_user_data_cards.dart new file mode 100644 index 0000000..a809d8f --- /dev/null +++ b/lib/widgets/home/home_user_data_cards.dart @@ -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 createState() => _UserDataDetailCardState(); +} + +class _UserDataDetailCardState extends State { + 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)), + ], + ), + ); + } +} diff --git a/lib/widgets/risk/music_risk_editors.dart b/lib/widgets/risk/music_risk_editors.dart new file mode 100644 index 0000000..ba24029 --- /dev/null +++ b/lib/widgets/risk/music_risk_editors.dart @@ -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 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 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 createState() => _UnlockMusicEditorCardState(); +} + +class _UnlockMusicEditorCardState extends State { + 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 onError; + final Widget pendingList; + + const CollectiblesEditorCard({ + super.key, + required this.enabled, + required this.onAdd, + required this.onError, + required this.pendingList, + }); + + @override + State createState() => _CollectiblesEditorCardState(); +} + +class _CollectiblesEditorCardState extends State { + 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( + 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, + ], + ), + ); + } +} diff --git a/lib/widgets/risk/risk_list_widgets.dart b/lib/widgets/risk/risk_list_widgets.dart new file mode 100644 index 0000000..76f2e44 --- /dev/null +++ b/lib/widgets/risk/risk_list_widgets.dart @@ -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 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 extends StatelessWidget { + final T value; + final String label; + final List> items; + final ValueChanged? 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( + 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), + ), + ); + } +} diff --git a/lib/widgets/risk/risk_run_mixin.dart b/lib/widgets/risk/risk_run_mixin.dart new file mode 100644 index 0000000..5c4d2e6 --- /dev/null +++ b/lib/widgets/risk/risk_run_mixin.dart @@ -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 on State { + 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 autoLogoutAndExit( + Future Function()? onExitToTitle, { + Duration delay = const Duration(seconds: 2), + }) async { + if (!riskAutoLogout || onExitToTitle == null) return; + await Future.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; +} diff --git a/lib/widgets/risk/risk_widgets.dart b/lib/widgets/risk/risk_widgets.dart new file mode 100644 index 0000000..60321ce --- /dev/null +++ b/lib/widgets/risk/risk_widgets.dart @@ -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 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 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), + ), + ), + ), + ], + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index b296b08..faa5367 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -114,7 +114,7 @@ packages: source: hosted version: "1.3.3" ffi: - dependency: transitive + dependency: "direct main" description: name: ffi sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" diff --git a/pubspec.yaml b/pubspec.yaml index aaff2fe..5035c40 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -32,6 +32,7 @@ dependencies: sdk: flutter http: ^1.6.0 + ffi: ^2.2.0 image_picker: ^1.2.2 crypto: ^3.0.7 encrypt: ^5.0.3 diff --git a/test/core/date_time_format_test.dart b/test/core/date_time_format_test.dart new file mode 100644 index 0000000..112d27c --- /dev/null +++ b/test/core/date_time_format_test.dart @@ -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'); + }); +} diff --git a/test/core/json_value_test.dart b/test/core/json_value_test.dart new file mode 100644 index 0000000..68e58bb --- /dev/null +++ b/test/core/json_value_test.dart @@ -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', () { + final map = {'a': 1}; + expect(jsonMap(map), same(map)); + expect(jsonMap(null), isEmpty); + expect(jsonMap([1, 2]), isEmpty); + }); + + test('jsonMapList 丢掉非 Map 元素,其余原样保留', () { + final rows = [ + {'id': 1}, + 'oops', + null, + {'id': 2}, + ]; + expect(jsonMapList(rows).map((e) => e['id']), [1, 2]); + expect(jsonMapList(null), isEmpty); + expect(jsonMapList({'id': 1}), isEmpty); + }); + }); +} diff --git a/test/home_lazy_tabs_test.dart b/test/home_lazy_tabs_test.dart new file mode 100644 index 0000000..3ae023d --- /dev/null +++ b/test/home_lazy_tabs_test.dart @@ -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); + }); +} diff --git a/test/login_page_test.dart b/test/login_page_test.dart index c15b9e9..a45b171 100644 --- a/test/login_page_test.dart +++ b/test/login_page_test.dart @@ -11,14 +11,17 @@ void main() { await tester.pumpWidget(const MyApp()); await tester.pump(); - // 72 位:截取的后 64 位里没有 SGWCMAID 前缀 + // 72 位:既不是 84 位二维码串,也不是二维码链接 await tester.enterText(find.byType(TextField), 'ABCDEF' * 12); await tester.pump(); await tester.tap(find.text(AppStrings.login)); await tester.pump(); - expect(find.text(AppStrings.qrTokenUnrecognized), findsOneWidget); + expect( + find.textContaining(AppStrings.qrTokenUnrecognized), + findsOneWidget, + ); }); testWidgets('粘贴连接信息时按钮切成「恢复会话」', (tester) async { diff --git a/test/qr_login_test.dart b/test/qr_login_test.dart new file mode 100644 index 0000000..a31440f --- /dev/null +++ b/test/qr_login_test.dart @@ -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); + }); + }); +} diff --git a/test/risk_widgets_test.dart b/test/risk_widgets_test.dart new file mode 100644 index 0000000..1bc8a9c --- /dev/null +++ b/test/risk_widgets_test.dart @@ -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(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() {} diff --git a/test/session_model_test.dart b/test/session_model_test.dart new file mode 100644 index 0000000..d505a85 --- /dev/null +++ b/test/session_model_test.dart @@ -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); + }); +} diff --git a/test/settings_tab_test.dart b/test/settings_tab_test.dart new file mode 100644 index 0000000..9bcd1c6 --- /dev/null +++ b/test/settings_tab_test.dart @@ -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(find.byType(Navigator)).canPop(), isFalse); + }); +} diff --git a/test/title_api_service_test.dart b/test/title_api_service_test.dart new file mode 100644 index 0000000..bfa90ea --- /dev/null +++ b/test/title_api_service_test.dart @@ -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 = []; + final sentCookies = []; + 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()), + ); + await expectLater( + service.getUserData(1), + throwsA(isA()), + ); + }, () => client); + + expect(captured, ['JSESSIONID=NEW']); + expect(service.cookies, 'JSESSIONID=NEW'); + expect(sentCookies, ['JSESSIONID=OLD', 'JSESSIONID=NEW']); + }); + + test('响应没有 JSESSIONID 时不回写', () async { + final captured = []; + final service = TitleApiService( + _config, + cookies: 'JSESSIONID=OLD', + onCookiesChanged: captured.add, + ); + + await http.runWithClient(() async { + await expectLater( + service.getUserData(1), + throwsA(isA()), + ); + }, () => 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); + }); +}