feat: 风险页新增数值修改、一键跑图与万花筒专区
- 修改总 Rating:只写 playerRating 与 userRating.rating,钳 0~99999, 每首歌各自的 ratingList 原样带回 - 添加舞里程(UserDetail.point):完整 int32 区间,支持累加与直接设定, 允许负值即扣减 - 一键跑图:isClear/isComplete 在客户端是从 distance 派生的,单发 flag 会被 冲掉,故把 distance 推过 End 里程针;unlockFlag 语义是反的 - 万花筒专区:发现门与获取钥匙落在 userKaleidxScopeList 同一行的两个布尔位, 钥匙不走 userItemList;上行是整行替换,所以先读回原行再叠加 - 旅行伙伴:修正误导性的示例 ID 1001(合法是三位数,表里没有的 ID 服务器照收 但客户端静默跳过),编组槽位可设定等级,上传后回读复查以区分失败原因 - 补 MIT 许可证
This commit is contained in:
1 parent
329845f228
commit
0808e367f7
15 files changed
+3571
-16
No files matched your search
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 chuxuehaocai
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -15,7 +15,7 @@ Android / iOS / Windows / macOS / Linux。
|
|||||||
| 主页 | Aime 登录(QR / 令牌)、Rating 详情、入坑信息、游玩统计、状态 |
|
| 主页 | Aime 登录(QR / 令牌)、Rating 详情、入坑信息、游玩统计、状态 |
|
||||||
| 票据 | 功能票查询与使用 |
|
| 票据 | 功能票查询与使用 |
|
||||||
| B50 | Best 50 成绩图,可导出 PNG(移植自 Empurple 的 `Best50ImageRenderer`) |
|
| B50 | Best 50 成绩图,可导出 PNG(移植自 Empurple 的 `Best50ImageRenderer`) |
|
||||||
| 风险 | 歌曲解锁、收藏品获取、旅行伙伴发放与编组 |
|
| 风险 | 歌曲解锁、收藏品获取、旅行伙伴发放与编组(含等级)、修改总 Rating、添加舞里程、一键跑图、万花筒专区 |
|
||||||
| 设置 | Title Server / Auth Server / 机器参数,支持配置导入导出 |
|
| 设置 | Title Server / Auth Server / 机器参数,支持配置导入导出 |
|
||||||
|
|
||||||
## 开发
|
## 开发
|
||||||
@@ -65,6 +65,51 @@ lib/
|
|||||||
`upsertUserAll.userCharacterList`(`{characterId, level, awakening, useCount}`);
|
`upsertUserAll.userCharacterList`(`{characterId, level, awakening, useCount}`);
|
||||||
搭档是 `ItemKind.Partner = 10`,走 `userItemList`。出战编组是
|
搭档是 `ItemKind.Partner = 10`,走 `userItemList`。出战编组是
|
||||||
`UserDetail.charaSlot`(`int[5]`,槽 0 为队长)。
|
`UserDetail.charaSlot`(`int[5]`,槽 0 为队长)。
|
||||||
|
- **角色 ID 必须在机台的 Chara 表里**:合法 ID 是三位数(101-105 / 201-205 /
|
||||||
|
301-306 / 392-395 / 401-405 / 501-505 / 601-605,取自客户端
|
||||||
|
`PlInformationProcess.AddDefaultCharacter`),不是四位数的 `1001`。填了表里
|
||||||
|
没有的 ID,服务器照样返回 `returnCode: 1`,但客户端 `CharacterSelectProces`
|
||||||
|
会静默跳过,游戏里看起来就是「加不上」。因此上传后会复查
|
||||||
|
`GetUserCharacterApi`,用来区分「服务器没写入」与「写入了但机台不认这个 ID」。
|
||||||
|
角色的 `awakening` 由客户端从 `level` 反推(`UserChara.CalcLevelToAwake`),
|
||||||
|
读回时并不取服务端值。
|
||||||
|
- **舞里程 = `UserDetail.point`**(余额),`totalPoint` 是累计获得量。客户端只在
|
||||||
|
「获得」时钳到 99999(`UserDetail.AddMile`),商店扣款走不带钳位的
|
||||||
|
`Point -= cost`,展示是裸的 `num.ToString()`,所以直接写更大的值不会被覆回。
|
||||||
|
取值是整个 int32 区间 `-2147483648 ~ 2147483647`——同 `playSpecial` 那次一样,
|
||||||
|
超出 C# `int` 会让服务器反序列化失败并回 500。允许负值,所以累加模式填负数
|
||||||
|
就是扣里程。
|
||||||
|
- **总 Rating 只改两处**:`userData.playerRating` 与 `userRating.rating`,取值钳在
|
||||||
|
`0 ~ 99999`。`ratingList` / `newRatingList` 里每首歌各自的 Rating 原样带回,
|
||||||
|
不重排也不改值。占位 playlog 的 `before/afterRating`(及 `DeluxRating`)一起对齐成
|
||||||
|
新写的值,否则存档里那条记录与 `playerRating` 自相矛盾。
|
||||||
|
- **万花筒的门和钥匙在同一行**:`upsertUserAll.userKaleidxScopeList`,
|
||||||
|
`UserKaleidxScope` 共 15 个字段、主键 `gateId`,「发现门」是 `isGateFound`、
|
||||||
|
「获取钥匙」是 `isKeyFound`。钥匙**不走** `userItemList`——`ExportUserItems`
|
||||||
|
从不输出 itemKind 15,下行也没有读它的路径;`15000000 + keyId` 只是 present
|
||||||
|
ID 的编码段位。
|
||||||
|
客户端状态机(`KaleidxScopeGateListController.cs:144-159`)里
|
||||||
|
`!isGateFound && isKeyFound` 判的是 **AnimState.None(隐形)**,不是「锁着的门」,
|
||||||
|
所以给钥匙必须连带把门标为已发现;`found && !key` 才是可见未解锁。
|
||||||
|
又因为上行是**整行替换**,页面上会先 `GetUserKaleidxScopeApi` 读回原行、
|
||||||
|
只叠加那两个布尔位再发出去,否则 best 成绩 / `playCount` / 日期会被清零。
|
||||||
|
合法 `gateId` 只存在于机台的 `KaleidxScopeGate.xml`,代码里除了
|
||||||
|
`ForceAddMasterKey` 写死的 `gateId = 7`(万能钥匙门)以外没有任何清单,
|
||||||
|
而且还要该门绑定的活动处于开启状态才会显示。
|
||||||
|
- **旅行伙伴等级**:真实等级区间 `1 ~ 999999`,界面显示的等级是 `level % 10000`、
|
||||||
|
转生次数是 `level ~/ 10000`,所以 `999999` = 99 转生 + 9999 级。给已有角色改等级要发
|
||||||
|
`isNewCharacterList` 对应位为 `'0'` 的更新行(`'1'` 是插入),且必须带上原有
|
||||||
|
`useCount`,否则整行覆盖会把使用次数清零。
|
||||||
|
- **一键跑图只发得动「完成态」**:区域进度是 `upsertUserAll.userMapList`,行结构
|
||||||
|
`{mapId, distance, isLock, isClear, isComplete, unlockFlag}`,主键 mapId。
|
||||||
|
`isClear` / `isComplete` 在客户端是从 `distance` 派生的
|
||||||
|
(`MapMaster.CreateUserDataMapList` 拿 distance 与该区域的 ReleaseFlag / End
|
||||||
|
里程针比较,没有对应针时甚至强制写回 false),所以单发 flag 下次进区域选择页就被冲掉;
|
||||||
|
这里把 `distance` 直接推到 `UserMapData.MaxDistance = 999999999`。`unlockFlag`
|
||||||
|
语义是反的(`IsFinishedOpening ? 0 : 1`,取 0 才是已开启),`isLock` 客户端根本不回读。
|
||||||
|
**该区域的收藏品发不了**:`区域 → 宝藏 → 物品` 的对应关系在机台的 `Map.xml` /
|
||||||
|
`MapTreasure.xml` 里(`DataManager.LoadMaps` / `LoadMapTresures` 从本地数据目录读,
|
||||||
|
不经 title server),App 没有这份表,页面里也明写了这个限制。
|
||||||
|
|
||||||
## 构建环境注意事项
|
## 构建环境注意事项
|
||||||
|
|
||||||
|
|||||||
+144
-1
@@ -254,13 +254,38 @@ class AppStrings {
|
|||||||
static const travelPartnerGrantTitle = '发放新的旅行伙伴';
|
static const travelPartnerGrantTitle = '发放新的旅行伙伴';
|
||||||
static const travelPartnerGrantEmpty = '未添加待发放角色。';
|
static const travelPartnerGrantEmpty = '未添加待发放角色。';
|
||||||
static const travelPartnerCharacterIdLabel = '旅行伙伴 ID';
|
static const travelPartnerCharacterIdLabel = '旅行伙伴 ID';
|
||||||
static const travelPartnerCharacterIdHint = '例如 1001';
|
static const travelPartnerCharacterIdHint = '例如 101';
|
||||||
static const travelPartnerNeedCharacterId = '请填写旅行伙伴 ID。';
|
static const travelPartnerNeedCharacterId = '请填写旅行伙伴 ID。';
|
||||||
static const travelPartnerAlreadyOwned = '该旅行伙伴已拥有,无需重复发放。';
|
static const travelPartnerAlreadyOwned = '该旅行伙伴已拥有,无需重复发放。';
|
||||||
|
|
||||||
|
static const travelPartnerIdRangeTitle = '角色 ID 对照';
|
||||||
|
static const travelPartnerIdRangeBody =
|
||||||
|
'101-105 · 201-205 · 301-306 · 392-395 · 401-405 · 501-505 · 601-605\n'
|
||||||
|
'编号按颜色分段,每段内递增。这个表取自客户端内置的默认发放清单,机台的 '
|
||||||
|
'Chara.xml 可能更多。\n\n'
|
||||||
|
'ID 不在机台表里时服务器照样保存,但客户端 CharacterSelectProces 会静默跳过,'
|
||||||
|
'游戏里就是「加不上」。';
|
||||||
|
static const travelPartnerIdUnknown = '该 ID 不在已知的默认角色清单里,机台表里也可能有,注意确认。';
|
||||||
|
|
||||||
|
// 上传后复查 GetUserCharacterApi,用来区分「服务器没存」和「存了但游戏不显示」
|
||||||
|
static String travelPartnerVerified(int count) =>
|
||||||
|
'旅行伙伴数据上传完成,复查 GetUserCharacterApi 已确认服务器保存了 $count 个角色。'
|
||||||
|
'若游戏里仍看不到,说明这些 ID 不在机台的 Chara 表里。';
|
||||||
|
static String travelPartnerNotSaved(String ids) =>
|
||||||
|
'数据包已被接受,但复查时服务器上没有 #$ids —— '
|
||||||
|
'服务器没有写入 upsertUserAll.userCharacterList。';
|
||||||
|
static String travelPartnerVerifyFailed(String error) =>
|
||||||
|
'旅行伙伴数据上传完成,但复查角色列表失败,无法确认是否写入:$error';
|
||||||
|
|
||||||
static const travelPartnerSlotTitle = '编组出战槽位';
|
static const travelPartnerSlotTitle = '编组出战槽位';
|
||||||
static const travelPartnerSlotNone = '空';
|
static const travelPartnerSlotNone = '空';
|
||||||
static String travelPartnerSlotLabel(int index) =>
|
static String travelPartnerSlotLabel(int index) =>
|
||||||
index == 0 ? '槽 0(队长)' : '槽 $index';
|
index == 0 ? '槽 0(队长)' : '槽 $index';
|
||||||
|
static const travelPartnerLevelLabel = '等级(留空不改)';
|
||||||
|
static const travelPartnerLevelHint = '1 ~ 999999';
|
||||||
|
static const travelPartnerLevelInvalid = '旅行伙伴等级需要在 1 ~ 999999 之间。';
|
||||||
|
static String travelPartnerLevelConverted(int realLevel) =>
|
||||||
|
'真实等级 $realLevel = ${realLevel ~/ 10000} 转生 + ${realLevel % 10000} 级';
|
||||||
static const travelPartnerCopySlot0 = '把槽 0 复制到其他槽位';
|
static const travelPartnerCopySlot0 = '把槽 0 复制到其他槽位';
|
||||||
static const travelPartnerCopyNeedSlot0 = '槽 0 还没有选择旅行伙伴。';
|
static const travelPartnerCopyNeedSlot0 = '槽 0 还没有选择旅行伙伴。';
|
||||||
static const travelPartnerNotOwnedHint = '编组失败:你并不拥有该旅行伙伴。';
|
static const travelPartnerNotOwnedHint = '编组失败:你并不拥有该旅行伙伴。';
|
||||||
@@ -270,6 +295,124 @@ class AppStrings {
|
|||||||
static const travelPartnerStepFetch = '拉取角色与用户数据';
|
static const travelPartnerStepFetch = '拉取角色与用户数据';
|
||||||
static const travelPartnerStepUpload = '上传旅行伙伴数据';
|
static const travelPartnerStepUpload = '上传旅行伙伴数据';
|
||||||
|
|
||||||
|
// MapTraveller 一键跑图(UserMap 走 userMapList,收藏品另走 userItemList)
|
||||||
|
static const mapFeatureTitle = '一键跑图';
|
||||||
|
static const mapFeatureDesc =
|
||||||
|
'把区域标记为已完成。isClear / isComplete 在客户端是从 distance 派生的,'
|
||||||
|
'所以这里把 distance 直接推到 999999999(UserMapData.MaxDistance),'
|
||||||
|
'超过任何一张图的 End 里程针,状态才不会被下一次进区域选择页冲掉。';
|
||||||
|
static const mapNotLoggedIn = '尚未登录游戏服务器,无法操作区域进度。';
|
||||||
|
static String mapCooldownNotice(int remaining) =>
|
||||||
|
'登录后需冷却 $ticketCooldownSeconds 秒,剩余 $remaining 秒后可操作区域进度。';
|
||||||
|
static const mapIdLabel = '区域 ID';
|
||||||
|
static const mapIdHint = '例如 550001';
|
||||||
|
static const mapNeedMapId = '请填写要标记完成的区域 ID。';
|
||||||
|
static const mapPendingEmpty = '列表为空,可在上方填写区域 ID 后添加。';
|
||||||
|
static const mapRun = '标记区域已完成';
|
||||||
|
static const mapRunning = '执行中...';
|
||||||
|
static const mapStepFetch = '拉取区域进度';
|
||||||
|
static const mapStepUpload = '上传区域进度';
|
||||||
|
static const mapFetchData = '拉取区域数据';
|
||||||
|
static const mapFetched = '区域数据已拉取';
|
||||||
|
static const mapNoData = '⚠️ 尚未拉取区域进度。';
|
||||||
|
static String mapCompleted(int mapId) => '区域 #$mapId 已标记为完成';
|
||||||
|
static String mapVerified(int count) =>
|
||||||
|
'上传完成,复查 GetUserMapApi 已确认 $count 个区域的进度被保存。'
|
||||||
|
'若游戏里区域仍显示未解锁,多半是 distance 还不够或该 mapId 不在机台表里。';
|
||||||
|
static String mapNotSaved(String ids) =>
|
||||||
|
'数据包已被接受,但复查时服务器上没有 #$ids 的完成记录 —— '
|
||||||
|
'服务器没有写入 upsertUserAll.userMapList。';
|
||||||
|
static String mapVerifyFailed(String error) =>
|
||||||
|
'上传完成,但复查区域进度失败,无法确认是否写入:$error';
|
||||||
|
static const mapCollectiblesNotice =
|
||||||
|
'只标记完成,不发放该区域的收藏品——对应关系在机台的 Map.xml / '
|
||||||
|
'MapTreasure.xml 里,App 拿不到。要收藏品请用「收藏品获取」。';
|
||||||
|
|
||||||
|
// Kaleidoscope 万花筒专区(UserKaleidxScope 走 userKaleidxScopeList)
|
||||||
|
static const kaleidxFeatureTitle = '万花筒专区';
|
||||||
|
static const kaleidxFeatureDesc =
|
||||||
|
'发现新的宿命之门、获取门的钥匙。两者都是 userKaleidxScopeList 里同一行的 '
|
||||||
|
'isGateFound / isKeyFound,钥匙不走 userItemList。每行是整行替换,'
|
||||||
|
'所以上传前会先读回原行的成绩与日期再合并。';
|
||||||
|
static const kaleidxNotLoggedIn = '尚未登录游戏服务器,无法操作万花筒。';
|
||||||
|
static String kaleidxCooldownNotice(int remaining) =>
|
||||||
|
'登录后需冷却 $ticketCooldownSeconds 秒,剩余 $remaining 秒后可操作万花筒。';
|
||||||
|
static const kaleidxGateIdLabel = '门 ID (gateId)';
|
||||||
|
static const kaleidxGateIdHint = '例如 3';
|
||||||
|
static const kaleidxNeedGateId = '请填写门 ID。';
|
||||||
|
static const kaleidxNeedAction = '请至少勾选一项:发现门或获取钥匙。';
|
||||||
|
static const kaleidxPendingEmpty = '列表为空,可在上方填写门 ID 后添加。';
|
||||||
|
static const kaleidxActionDiscover = '发现门 (isGateFound)';
|
||||||
|
static const kaleidxActionKey = '获取钥匙 (isKeyFound)';
|
||||||
|
static const kaleidxKeyNeedsGate =
|
||||||
|
'只给钥匙不给门的话这扇门是隐形的(客户端状态机把 !found && key 判为 None),'
|
||||||
|
'所以勾了钥匙会连带把门标为已发现。';
|
||||||
|
static const kaleidxRun = '执行万花筒操作';
|
||||||
|
static const kaleidxRunning = '执行中...';
|
||||||
|
static const kaleidxStepFetch = '拉取万花筒进度';
|
||||||
|
static const kaleidxStepUpload = '上传万花筒进度';
|
||||||
|
static const kaleidxFetchData = '拉取门进度';
|
||||||
|
static const kaleidxFetched = '门进度已拉取';
|
||||||
|
static const kaleidxNoData = '⚠️ 尚未拉取门进度,无法合并原行字段。';
|
||||||
|
static const kaleidxGateIdDuplicated = '同一扇门只能出现一次。';
|
||||||
|
static String kaleidxVerified(int count) =>
|
||||||
|
'上传完成,复查 GetUserKaleidxScopeApi 已确认 $count 扇门的状态被保存。'
|
||||||
|
'若游戏里仍看不到,说明这个 gateId 不在机台的 KaleidxScopeGate.xml 里,'
|
||||||
|
'或它绑定的活动没在开。';
|
||||||
|
static String kaleidxNotSaved(String ids) =>
|
||||||
|
'数据包已被接受,但复查时服务器上没有 #$ids 的行 —— '
|
||||||
|
'服务器没有写入 upsertUserAll.userKaleidxScopeList。';
|
||||||
|
static String kaleidxVerifyFailed(String error) =>
|
||||||
|
'上传完成,但复查万花筒进度失败,无法确认是否写入:$error';
|
||||||
|
|
||||||
|
static String kaleidxGateState(bool found, bool key, bool clear) {
|
||||||
|
if (!found && key) return '隐形(有钥匙但没发现门)';
|
||||||
|
if (!found) return '未见过';
|
||||||
|
if (!key) return '已发现 · 未解锁';
|
||||||
|
if (!clear) return '可挑战';
|
||||||
|
return '已通关';
|
||||||
|
}
|
||||||
|
|
||||||
|
// NumberPatch 数值修改(修改 Rating / 添加舞里程),都走 UpsertUserAllApi
|
||||||
|
static const numberPatchNotLoggedIn = '尚未登录游戏服务器,无法执行修改。';
|
||||||
|
static const numberPatchRun = '执行修改';
|
||||||
|
static const numberPatchStepUpload = '上传修改后的数据';
|
||||||
|
static String numberPatchCooldownNotice(int remaining) =>
|
||||||
|
'登录后需冷却 $ticketCooldownSeconds 秒,剩余 $remaining 秒后可执行修改。';
|
||||||
|
static String numberPatchOutOfRange(int min, int max) =>
|
||||||
|
'数值超出范围($min ~ $max)。';
|
||||||
|
static String numberPatchRangeHint(int min, int max) => '可填 $min ~ $max';
|
||||||
|
static String numberPatchPreview(String label, int current, int target) =>
|
||||||
|
'$label: $current → $target';
|
||||||
|
|
||||||
|
// Rating 修改(只改总 Rating,不动每首歌的 Rating)
|
||||||
|
static const ratingFeatureTitle = '修改 Rating';
|
||||||
|
static const ratingFeatureDesc =
|
||||||
|
'只写总 Rating(userData.playerRating 与 userRating.rating 两处),取值 0 ~ 99999。'
|
||||||
|
'ratingList / newRatingList 里每首歌各自的 Rating 原样带回不动。'
|
||||||
|
'包里带一条占位 playlog,before/after Rating 一并改成新旧值。';
|
||||||
|
static const ratingValueLabel = '目标总 Rating';
|
||||||
|
static const ratingValueHint = '例如 15000';
|
||||||
|
static const ratingNeedValue = '请填写目标 Rating。';
|
||||||
|
static const ratingSuccess = 'Rating 上传完成';
|
||||||
|
static const ratingCurrentValue = '当前总 Rating';
|
||||||
|
|
||||||
|
// MaiMile 舞里程(UserDetail.point 余额 / totalPoint 累计)
|
||||||
|
static const maiMileFeatureTitle = '添加舞里程';
|
||||||
|
static const maiMileFeatureDesc =
|
||||||
|
'舞里程就是 UserDetail.point(余额),totalPoint 为累计获得量。'
|
||||||
|
'客户端只在「获得」时把余额钳到 99999(UserDetail.AddMile),'
|
||||||
|
'商店扣款走不带钳位的 Point -= cost,所以写更大的值不会被覆回。'
|
||||||
|
'取值是整个 int32 区间,累加模式填负数即为扣减。';
|
||||||
|
static const maiMileValueLabel = '舞里程数值';
|
||||||
|
static const maiMileValueHint = '例如 10000';
|
||||||
|
static const maiMileNeedValue = '请填写舞里程数值。';
|
||||||
|
static const maiMileModeLabel = '写入方式';
|
||||||
|
static const maiMileModeAdd = '累加(当前值 + N)';
|
||||||
|
static const maiMileModeSet = '直接设定为 N';
|
||||||
|
static const maiMileCurrentBalance = '当前舞里程';
|
||||||
|
static const maiMileSuccess = '舞里程上传完成';
|
||||||
|
|
||||||
// HighRiskFeature hub (中转页)
|
// HighRiskFeature hub (中转页)
|
||||||
static const musicRiskHubTitle = '高危功能';
|
static const musicRiskHubTitle = '高危功能';
|
||||||
static const musicRiskHubDesc = '以下功能会向服务器发送伪造数据,属于高风险操作,请谨慎使用。';
|
static const musicRiskHubDesc = '以下功能会向服务器发送伪造数据,属于高风险操作,请谨慎使用。';
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
/// `Net.VO.Mai2.UserKaleidxScope`(万花筒 / 宿命之门),走
|
||||||
|
/// `upsertUserAll.userKaleidxScopeList`,主键是 `gateId`。
|
||||||
|
///
|
||||||
|
/// 依据反编译客户端:
|
||||||
|
/// - 「发现门」与「获取钥匙」都在这同一张表里,分别是 `isGateFound` 与
|
||||||
|
/// `isKeyFound`。钥匙**不**走 `userItemList`——`ExportUserItems` 从不输出
|
||||||
|
/// itemKind 15,下行也没有 `GetUserItem(KaleidxScopeKey)` 这一步。
|
||||||
|
/// - 状态机在 `KaleidxScopeGateListController.cs:144-159`,四个组合里:
|
||||||
|
/// `!found && key` -> AnimState.None(**隐形**,不是锁着的门)
|
||||||
|
/// ` found && !key` -> Close(可见、未解锁)
|
||||||
|
/// ` found && key && !clear` -> Open(可挑战)
|
||||||
|
/// ` found && key && clear` -> Clear
|
||||||
|
/// 所以只给钥匙不给门等于什么都没给。
|
||||||
|
class UserKaleidxScopeBean {
|
||||||
|
final int gateId;
|
||||||
|
final bool isGateFound;
|
||||||
|
final bool isKeyFound;
|
||||||
|
final bool isClear;
|
||||||
|
final int totalRestLife;
|
||||||
|
final int totalAchievement;
|
||||||
|
|
||||||
|
/// 原样拼写是 `Deluxscore`(不是 `DeluxeScore`),改名服务器就认不出了。
|
||||||
|
final int totalDeluxscore;
|
||||||
|
final int bestAchievement;
|
||||||
|
final int bestDeluxscore;
|
||||||
|
final String bestAchievementDate;
|
||||||
|
final String bestDeluxscoreDate;
|
||||||
|
final int playCount;
|
||||||
|
final String clearDate;
|
||||||
|
final String lastPlayDate;
|
||||||
|
final bool isInfoWatched;
|
||||||
|
|
||||||
|
const UserKaleidxScopeBean({
|
||||||
|
required this.gateId,
|
||||||
|
this.isGateFound = false,
|
||||||
|
this.isKeyFound = false,
|
||||||
|
this.isClear = false,
|
||||||
|
this.totalRestLife = 0,
|
||||||
|
this.totalAchievement = 0,
|
||||||
|
this.totalDeluxscore = 0,
|
||||||
|
this.bestAchievement = 0,
|
||||||
|
this.bestDeluxscore = 0,
|
||||||
|
this.bestAchievementDate = '',
|
||||||
|
this.bestDeluxscoreDate = '',
|
||||||
|
this.playCount = 0,
|
||||||
|
this.clearDate = '',
|
||||||
|
this.lastPlayDate = '',
|
||||||
|
this.isInfoWatched = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// wire 上的完整 15 个字段。发出去的是整行替换,所以没列在这里的字段
|
||||||
|
/// 必须从服务器原值带回来,否则会被清零。
|
||||||
|
Map<String, dynamic> toWireJson() => {
|
||||||
|
'gateId': gateId,
|
||||||
|
'isGateFound': isGateFound,
|
||||||
|
'isKeyFound': isKeyFound,
|
||||||
|
'isClear': isClear,
|
||||||
|
'totalRestLife': totalRestLife,
|
||||||
|
'totalAchievement': totalAchievement,
|
||||||
|
'totalDeluxscore': totalDeluxscore,
|
||||||
|
'bestAchievement': bestAchievement,
|
||||||
|
'bestDeluxscore': bestDeluxscore,
|
||||||
|
'bestAchievementDate': bestAchievementDate,
|
||||||
|
'bestDeluxscoreDate': bestDeluxscoreDate,
|
||||||
|
'playCount': playCount,
|
||||||
|
'clearDate': clearDate,
|
||||||
|
'lastPlayDate': lastPlayDate,
|
||||||
|
'isInfoWatched': isInfoWatched,
|
||||||
|
};
|
||||||
|
|
||||||
|
UserKaleidxScopeBean copyWith({
|
||||||
|
bool? isGateFound,
|
||||||
|
bool? isKeyFound,
|
||||||
|
bool? isClear,
|
||||||
|
}) => UserKaleidxScopeBean(
|
||||||
|
gateId: gateId,
|
||||||
|
isGateFound: isGateFound ?? this.isGateFound,
|
||||||
|
isKeyFound: isKeyFound ?? this.isKeyFound,
|
||||||
|
isClear: isClear ?? this.isClear,
|
||||||
|
totalRestLife: totalRestLife,
|
||||||
|
totalAchievement: totalAchievement,
|
||||||
|
totalDeluxscore: totalDeluxscore,
|
||||||
|
bestAchievement: bestAchievement,
|
||||||
|
bestDeluxscore: bestDeluxscore,
|
||||||
|
bestAchievementDate: bestAchievementDate,
|
||||||
|
bestDeluxscoreDate: bestDeluxscoreDate,
|
||||||
|
playCount: playCount,
|
||||||
|
clearDate: clearDate,
|
||||||
|
lastPlayDate: lastPlayDate,
|
||||||
|
isInfoWatched: isInfoWatched,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// 服务器上还没有这扇门时的新行。`isGateFound` 恒为 true——不存在「只给钥匙
|
||||||
|
/// 不给门」这种可用状态(见类注释的状态机),新行其余字段取 struct 默认值。
|
||||||
|
static UserKaleidxScopeBean discovered(int gateId, {required bool giveKey}) =>
|
||||||
|
UserKaleidxScopeBean(
|
||||||
|
gateId: gateId,
|
||||||
|
isGateFound: true,
|
||||||
|
isKeyFound: giveKey,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// 在服务器原行上叠加本次动作。**必须**走这里而不是新建一行:整行替换会把
|
||||||
|
/// best 成绩、playCount、clearDate 这些字段清零。
|
||||||
|
UserKaleidxScopeBean withActions({
|
||||||
|
required bool discover,
|
||||||
|
required bool giveKey,
|
||||||
|
}) => copyWith(
|
||||||
|
// 给钥匙连带发现门,否则这行在客户端是隐形的。
|
||||||
|
isGateFound: isGateFound || discover || giveKey,
|
||||||
|
isKeyFound: isKeyFound || giveKey,
|
||||||
|
);
|
||||||
|
|
||||||
|
static int _toInt(dynamic v) {
|
||||||
|
if (v is int) return v;
|
||||||
|
if (v is num) return v.toInt();
|
||||||
|
if (v is String) return int.tryParse(v) ?? 0;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool _toBool(dynamic v) {
|
||||||
|
if (v is bool) return v;
|
||||||
|
if (v is num) return v != 0;
|
||||||
|
if (v is String) return v == '1' || v.toLowerCase() == 'true';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
factory UserKaleidxScopeBean.fromJson(Map<String, dynamic> json) =>
|
||||||
|
UserKaleidxScopeBean(
|
||||||
|
gateId: _toInt(json['gateId']),
|
||||||
|
isGateFound: _toBool(json['isGateFound']),
|
||||||
|
isKeyFound: _toBool(json['isKeyFound']),
|
||||||
|
isClear: _toBool(json['isClear']),
|
||||||
|
totalRestLife: _toInt(json['totalRestLife']),
|
||||||
|
totalAchievement: _toInt(json['totalAchievement']),
|
||||||
|
totalDeluxscore: _toInt(json['totalDeluxscore']),
|
||||||
|
bestAchievement: _toInt(json['bestAchievement']),
|
||||||
|
bestDeluxscore: _toInt(json['bestDeluxscore']),
|
||||||
|
bestAchievementDate: json['bestAchievementDate']?.toString() ?? '',
|
||||||
|
bestDeluxscoreDate: json['bestDeluxscoreDate']?.toString() ?? '',
|
||||||
|
playCount: _toInt(json['playCount']),
|
||||||
|
clearDate: json['clearDate']?.toString() ?? '',
|
||||||
|
lastPlayDate: json['lastPlayDate']?.toString() ?? '',
|
||||||
|
isInfoWatched: _toBool(json['isInfoWatched']),
|
||||||
|
);
|
||||||
|
|
||||||
|
static List<UserKaleidxScopeBean> listFromResponse(
|
||||||
|
Map<String, dynamic> json,
|
||||||
|
) {
|
||||||
|
final raw = json['userKaleidxScopeList'];
|
||||||
|
if (raw is! List) return const [];
|
||||||
|
return raw
|
||||||
|
.whereType<Map<String, dynamic>>()
|
||||||
|
.map(UserKaleidxScopeBean.fromJson)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,11 +6,14 @@ import '../config/responsive.dart';
|
|||||||
import '../config/strings.dart';
|
import '../config/strings.dart';
|
||||||
import '../config/title_server_config.dart';
|
import '../config/title_server_config.dart';
|
||||||
import 'collectibles_page.dart';
|
import 'collectibles_page.dart';
|
||||||
|
import 'kaleidx_scope_page.dart';
|
||||||
|
import 'map_traverse_page.dart';
|
||||||
|
import 'number_patch_page.dart';
|
||||||
import 'travel_partner_page.dart';
|
import 'travel_partner_page.dart';
|
||||||
import 'unlock_music_page.dart';
|
import 'unlock_music_page.dart';
|
||||||
|
|
||||||
/// 高危功能中转页:导航到 UnlockMusic (解锁歌曲) / 收藏品获取。
|
/// 高危功能中转页:导航到解锁歌曲 / 收藏品获取 / 旅行伙伴 / 数值修改。
|
||||||
/// 两个 feature 互相独立,各自的执行流程互不影响。
|
/// 各 feature 互相独立,执行流程互不影响。
|
||||||
class HighRiskFeaturePage extends StatefulWidget {
|
class HighRiskFeaturePage extends StatefulWidget {
|
||||||
final int userId;
|
final int userId;
|
||||||
final String? cookies;
|
final String? cookies;
|
||||||
@@ -135,6 +138,49 @@ class _HighRiskFeaturePageState extends State<HighRiskFeaturePage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _openNumberPatch(NumberPatchMode mode) {
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => NumberPatchPage(
|
||||||
|
userId: widget.userId,
|
||||||
|
cookies: widget.cookies,
|
||||||
|
loginDateTime: widget.loginDateTime,
|
||||||
|
loginId: widget.loginId,
|
||||||
|
onExitToTitle: widget.onExitToTitle,
|
||||||
|
mode: mode,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _openMapTraverse() {
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => MapTraversePage(
|
||||||
|
userId: widget.userId,
|
||||||
|
cookies: widget.cookies,
|
||||||
|
loginDateTime: widget.loginDateTime,
|
||||||
|
loginId: widget.loginId,
|
||||||
|
onExitToTitle: widget.onExitToTitle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _openKaleidxScope() {
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => KaleidxScopePage(
|
||||||
|
userId: widget.userId,
|
||||||
|
cookies: widget.cookies,
|
||||||
|
loginDateTime: widget.loginDateTime,
|
||||||
|
loginId: widget.loginId,
|
||||||
|
onExitToTitle: widget.onExitToTitle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
@@ -191,6 +237,38 @@ class _HighRiskFeaturePageState extends State<HighRiskFeaturePage> {
|
|||||||
desc: AppStrings.travelPartnerFeatureDesc,
|
desc: AppStrings.travelPartnerFeatureDesc,
|
||||||
onTap: _openTravelPartner,
|
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,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,695 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../config/responsive.dart';
|
||||||
|
import '../config/strings.dart';
|
||||||
|
import '../config/title_server_config.dart';
|
||||||
|
import '../models/user_kaleidx_scope.dart';
|
||||||
|
import '../services/title_api_service.dart';
|
||||||
|
import '../services/user_all_payload_builder.dart';
|
||||||
|
|
||||||
|
/// 万花筒专区:发现新的宿命之门 + 获取门的钥匙。
|
||||||
|
///
|
||||||
|
/// 两个动作落在 `upsertUserAll.userKaleidxScopeList` 同一行的两个布尔位上
|
||||||
|
/// (`isGateFound` / `isKeyFound`)。钥匙不走 `userItemList`:
|
||||||
|
/// `ExportUserItems` 从不输出 itemKind 15,下行也没有读它的路径。
|
||||||
|
class KaleidxScopePage extends StatefulWidget {
|
||||||
|
final int userId;
|
||||||
|
final String? cookies;
|
||||||
|
final int? loginDateTime;
|
||||||
|
final int? loginId;
|
||||||
|
final Future<void> Function()? onExitToTitle;
|
||||||
|
|
||||||
|
const KaleidxScopePage({
|
||||||
|
super.key,
|
||||||
|
required this.userId,
|
||||||
|
this.cookies,
|
||||||
|
this.loginDateTime,
|
||||||
|
this.loginId,
|
||||||
|
this.onExitToTitle,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<KaleidxScopePage> createState() => _KaleidxScopePageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
enum _Step { idle, fetchData, upload, logout, complete, failed }
|
||||||
|
|
||||||
|
class _KaleidxScopePageState extends State<KaleidxScopePage> {
|
||||||
|
/// 与其它高危功能一致:不写真实成绩,playlog 用同一条占位记录。
|
||||||
|
static const int _placeholderMusicId = 11538;
|
||||||
|
|
||||||
|
final _gateIdController = TextEditingController();
|
||||||
|
final List<int> _pending = [];
|
||||||
|
|
||||||
|
bool _discover = true;
|
||||||
|
bool _giveKey = false;
|
||||||
|
|
||||||
|
Map<String, Map<String, dynamic>> _userAllData = const {};
|
||||||
|
List<UserKaleidxScopeBean> _serverScopes = const [];
|
||||||
|
|
||||||
|
bool _loading = false;
|
||||||
|
bool _running = false;
|
||||||
|
bool _autoLogout = true;
|
||||||
|
_Step _step = _Step.idle;
|
||||||
|
String _stepMessage = '';
|
||||||
|
|
||||||
|
Timer? _cooldownTimer;
|
||||||
|
int _cooldownRemaining = 0;
|
||||||
|
|
||||||
|
bool get _loggedIn => widget.loginDateTime != null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_syncCooldown();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(covariant KaleidxScopePage oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (oldWidget.loginDateTime != widget.loginDateTime) {
|
||||||
|
_syncCooldown();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_cooldownTimer?.cancel();
|
||||||
|
_gateIdController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _syncCooldown() {
|
||||||
|
_cooldownTimer?.cancel();
|
||||||
|
final loginDateTime = widget.loginDateTime;
|
||||||
|
if (loginDateTime == null) {
|
||||||
|
setState(() => _cooldownRemaining = 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() => _cooldownRemaining = _computeRemaining(loginDateTime));
|
||||||
|
if (_cooldownRemaining <= 0) return;
|
||||||
|
_cooldownTimer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
final remaining = _computeRemaining(loginDateTime);
|
||||||
|
setState(() => _cooldownRemaining = remaining);
|
||||||
|
if (remaining <= 0) {
|
||||||
|
_cooldownTimer?.cancel();
|
||||||
|
_cooldownTimer = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
int _computeRemaining(int loginDateTime) {
|
||||||
|
final nowSec = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||||
|
final remaining =
|
||||||
|
AppStrings.ticketCooldownSeconds - (nowSec - loginDateTime);
|
||||||
|
return remaining < 0 ? 0 : remaining;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _snack(String message) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _addPending() {
|
||||||
|
final id = int.tryParse(_gateIdController.text.trim()) ?? -1;
|
||||||
|
if (id <= 0) {
|
||||||
|
_snack(AppStrings.kaleidxNeedGateId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_pending.contains(id)) {
|
||||||
|
_snack(AppStrings.kaleidxGateIdDuplicated);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_pending.add(id);
|
||||||
|
_gateIdController.clear();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
UserKaleidxScopeBean? _serverRow(int gateId) {
|
||||||
|
for (final row in _serverScopes) {
|
||||||
|
if (row.gateId == gateId) return row;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 整行替换,所以已存在的门必须拿服务器原行做底,只改那两个布尔位。
|
||||||
|
List<UserKaleidxScopeBean> _buildRows() {
|
||||||
|
return [
|
||||||
|
for (final id in _pending)
|
||||||
|
_serverRow(id)?.withActions(discover: _discover, giveKey: _giveKey) ??
|
||||||
|
UserKaleidxScopeBean.discovered(id, giveKey: _giveKey),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _fetchData() async {
|
||||||
|
if (!TitleServerConfigHolder().isConfigured) return;
|
||||||
|
setState(() => _loading = true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final config = TitleServerConfigHolder().config!;
|
||||||
|
final service = TitleApiService(config, cookies: widget.cookies);
|
||||||
|
final results = await Future.wait([
|
||||||
|
service.fetchUserAllData(widget.userId),
|
||||||
|
service.getUserKaleidxScopes(widget.userId),
|
||||||
|
]);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_userAllData = results[0] as Map<String, Map<String, dynamic>>;
|
||||||
|
_serverScopes = results[1] as List<UserKaleidxScopeBean>;
|
||||||
|
_loading = false;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _loading = false);
|
||||||
|
_snack('${AppStrings.loadFailed}: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _run() async {
|
||||||
|
if (!TitleServerConfigHolder().isConfigured) {
|
||||||
|
_snack(AppStrings.ticketNotConfigured);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final loginDateTime = widget.loginDateTime;
|
||||||
|
final loginId = widget.loginId;
|
||||||
|
if (loginDateTime == null || loginId == null) {
|
||||||
|
_snack(AppStrings.kaleidxNotLoggedIn);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_cooldownRemaining > 0) {
|
||||||
|
_snack(AppStrings.kaleidxCooldownNotice(_cooldownRemaining));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_pending.isEmpty) {
|
||||||
|
_snack(AppStrings.kaleidxNeedGateId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!_discover && !_giveKey) {
|
||||||
|
_snack(AppStrings.kaleidxNeedAction);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() => _running = true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final config = TitleServerConfigHolder().config!;
|
||||||
|
final service = TitleApiService(config, cookies: widget.cookies);
|
||||||
|
final builder = UserAllPayloadBuilder(config);
|
||||||
|
|
||||||
|
var data = _userAllData;
|
||||||
|
if (data.isEmpty || _serverScopes.isEmpty) {
|
||||||
|
_updateStep(_Step.fetchData);
|
||||||
|
final results = await Future.wait([
|
||||||
|
service.fetchUserAllData(widget.userId),
|
||||||
|
service.getUserKaleidxScopes(widget.userId),
|
||||||
|
]);
|
||||||
|
data = results[0] as Map<String, Map<String, dynamic>>;
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_userAllData = data;
|
||||||
|
_serverScopes = results[1] as List<UserKaleidxScopeBean>;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_updateStep(_Step.upload);
|
||||||
|
final packet = builder.build(
|
||||||
|
userId: widget.userId,
|
||||||
|
loginId: loginId,
|
||||||
|
loginDateTime: loginDateTime,
|
||||||
|
musicData: _placeholderMusicData(),
|
||||||
|
generalUserInfo: data,
|
||||||
|
);
|
||||||
|
|
||||||
|
final rows = _buildRows();
|
||||||
|
// 每一位 '1' 插入 / '0' 更新,判据是服务器有没有同 gateId 的行。
|
||||||
|
final flags = [
|
||||||
|
for (final row in rows) _serverRow(row.gateId) == null ? '1' : '0',
|
||||||
|
].join();
|
||||||
|
builder.applyKaleidxScopePatch(
|
||||||
|
packet,
|
||||||
|
scopes: [for (final row in rows) row.toWireJson()],
|
||||||
|
newFlags: flags,
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.upsertUserAll(packet, widget.userId);
|
||||||
|
_updateStep(_Step.complete, await _verify(service));
|
||||||
|
|
||||||
|
if (_autoLogout && widget.onExitToTitle != null) {
|
||||||
|
await Future.delayed(const Duration(seconds: 2));
|
||||||
|
if (!mounted) return;
|
||||||
|
_updateStep(_Step.logout, AppStrings.exitingToTitle);
|
||||||
|
await widget.onExitToTitle!();
|
||||||
|
}
|
||||||
|
} on TitleApiException catch (e) {
|
||||||
|
_updateStep(_Step.failed, e.message);
|
||||||
|
} catch (e) {
|
||||||
|
_updateStep(_Step.failed, '$e');
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _running = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> _verify(TitleApiService service) async {
|
||||||
|
final requested = List<int>.from(_pending);
|
||||||
|
List<UserKaleidxScopeBean> rows;
|
||||||
|
try {
|
||||||
|
rows = await service.getUserKaleidxScopes(widget.userId);
|
||||||
|
} catch (e) {
|
||||||
|
return AppStrings.kaleidxVerifyFailed('$e');
|
||||||
|
}
|
||||||
|
if (mounted) setState(() => _serverScopes = rows);
|
||||||
|
|
||||||
|
final saved = rows.where((r) => requested.contains(r.gateId));
|
||||||
|
final missing = requested
|
||||||
|
.where((id) => !rows.any((r) => r.gateId == id))
|
||||||
|
.toList();
|
||||||
|
if (missing.isNotEmpty) {
|
||||||
|
return AppStrings.kaleidxNotSaved(missing.join(' / '));
|
||||||
|
}
|
||||||
|
return AppStrings.kaleidxVerified(saved.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> _placeholderMusicData() => {
|
||||||
|
'musicId': _placeholderMusicId,
|
||||||
|
'level': 0,
|
||||||
|
'playCount': 1,
|
||||||
|
'achievement': 0,
|
||||||
|
'comboStatus': 0,
|
||||||
|
'syncStatus': 0,
|
||||||
|
'deluxscoreMax': 0,
|
||||||
|
'scoreRank': 0,
|
||||||
|
'extNum1': 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
void _updateStep(_Step 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 (!_loggedIn)
|
||||||
|
_notice(theme, AppStrings.kaleidxNotLoggedIn, error: true),
|
||||||
|
if (_loggedIn && _cooldownRemaining > 0)
|
||||||
|
_notice(
|
||||||
|
theme,
|
||||||
|
AppStrings.kaleidxCooldownNotice(_cooldownRemaining),
|
||||||
|
),
|
||||||
|
_card(
|
||||||
|
theme,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_titleRow(
|
||||||
|
theme,
|
||||||
|
Icons.science_outlined,
|
||||||
|
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(theme),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_runButton(theme),
|
||||||
|
if (_step != _Step.idle) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_progressCard(theme),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _notice(ThemeData theme, String text, {bool error = false}) {
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: error
|
||||||
|
? theme.colorScheme.errorContainer
|
||||||
|
: theme.colorScheme.tertiaryContainer.withValues(alpha: 0.5),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
text,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: error
|
||||||
|
? theme.colorScheme.onErrorContainer
|
||||||
|
: theme.colorScheme.onTertiaryContainer,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _card(ThemeData theme, {required Widget child}) {
|
||||||
|
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: child),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _titleRow(ThemeData theme, IconData icon, String title) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 18, color: theme.colorScheme.primary),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
title,
|
||||||
|
style: theme.textTheme.labelLarge?.copyWith(
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _checkRow({
|
||||||
|
required String label,
|
||||||
|
required bool value,
|
||||||
|
required bool enabled,
|
||||||
|
required ValueChanged<bool> onChanged,
|
||||||
|
}) {
|
||||||
|
return InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
onTap: enabled ? () => onChanged(!value) : null,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Checkbox(
|
||||||
|
value: value,
|
||||||
|
onChanged: enabled ? (v) => onChanged(v ?? false) : null,
|
||||||
|
),
|
||||||
|
Expanded(child: Text(label)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _inputCard(ThemeData theme) {
|
||||||
|
final enabled = !_running;
|
||||||
|
return _card(
|
||||||
|
theme,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: _gateIdController,
|
||||||
|
enabled: enabled,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: AppStrings.kaleidxGateIdLabel,
|
||||||
|
hintText: AppStrings.kaleidxGateIdHint,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
contentPadding: const EdgeInsets.all(14),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: enabled ? _addPending : 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: 4),
|
||||||
|
_checkRow(
|
||||||
|
label: AppStrings.kaleidxActionDiscover,
|
||||||
|
value: _discover,
|
||||||
|
enabled: enabled,
|
||||||
|
onChanged: (v) => setState(() => _discover = v),
|
||||||
|
),
|
||||||
|
_checkRow(
|
||||||
|
label: AppStrings.kaleidxActionKey,
|
||||||
|
value: _giveKey,
|
||||||
|
enabled: enabled,
|
||||||
|
onChanged: (v) => setState(() => _giveKey = v),
|
||||||
|
),
|
||||||
|
if (_giveKey) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
AppStrings.kaleidxKeyNeedsGate,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
height: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
for (final id in _pending)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||||
|
child: Text(
|
||||||
|
'#$id ${_stateLabelFor(id)}',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_pending.isEmpty)
|
||||||
|
Text(
|
||||||
|
AppStrings.kaleidxPendingEmpty,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _stateLabelFor(int gateId) {
|
||||||
|
final row = _serverRow(gateId);
|
||||||
|
if (row == null) {
|
||||||
|
return _userAllData.isEmpty
|
||||||
|
? ''
|
||||||
|
: AppStrings.kaleidxGateState(false, false, false);
|
||||||
|
}
|
||||||
|
return AppStrings.kaleidxGateState(
|
||||||
|
row.isGateFound,
|
||||||
|
row.isKeyFound,
|
||||||
|
row.isClear,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _fetchCard(ThemeData theme) {
|
||||||
|
final hasData = _userAllData.isNotEmpty;
|
||||||
|
return _card(
|
||||||
|
theme,
|
||||||
|
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 _autoLogoutToggle(ThemeData theme) {
|
||||||
|
final enabled = widget.onExitToTitle != null && !_running;
|
||||||
|
return _card(
|
||||||
|
theme,
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
onTap: enabled ? () => setState(() => _autoLogout = !_autoLogout) : null,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Checkbox(
|
||||||
|
value: _autoLogout,
|
||||||
|
onChanged: enabled
|
||||||
|
? (v) => setState(() => _autoLogout = v ?? false)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
AppStrings.autoLogoutAndExit,
|
||||||
|
style: theme.textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _runButton(ThemeData theme) {
|
||||||
|
return SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: FilledButton.icon(
|
||||||
|
onPressed: _running || !_loggedIn ? 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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _progressCard(ThemeData theme) {
|
||||||
|
final isFailed = _step == _Step.failed;
|
||||||
|
return _card(
|
||||||
|
theme,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_stepRow(theme, _Step.fetchData, AppStrings.kaleidxStepFetch),
|
||||||
|
_stepRow(theme, _Step.upload, AppStrings.kaleidxStepUpload),
|
||||||
|
if (_autoLogout) _stepRow(theme, _Step.logout, AppStrings.stepLogout),
|
||||||
|
if (_stepMessage.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isFailed
|
||||||
|
? theme.colorScheme.errorContainer
|
||||||
|
: theme.colorScheme.surfaceContainerHighest
|
||||||
|
.withValues(alpha: 0.5),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
_stepMessage,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
color: isFailed
|
||||||
|
? theme.colorScheme.onErrorContainer
|
||||||
|
: theme.colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _stepRow(ThemeData theme, _Step step, String label) {
|
||||||
|
final order = [_Step.fetchData, _Step.upload, _Step.logout, _Step.complete];
|
||||||
|
final current = _step == _Step.failed ? _Step.upload : _step;
|
||||||
|
final done = order.indexOf(current) > order.indexOf(step);
|
||||||
|
final active = _step == step;
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
done
|
||||||
|
? Icons.check_circle
|
||||||
|
: active
|
||||||
|
? Icons.radio_button_checked
|
||||||
|
: Icons.radio_button_unchecked,
|
||||||
|
size: 18,
|
||||||
|
color: done
|
||||||
|
? Colors.green
|
||||||
|
: active
|
||||||
|
? theme.colorScheme.primary
|
||||||
|
: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(child: Text(label, style: theme.textTheme.bodySmall)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,623 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../config/responsive.dart';
|
||||||
|
import '../config/strings.dart';
|
||||||
|
import '../config/title_server_config.dart';
|
||||||
|
import '../services/title_api_service.dart';
|
||||||
|
import '../services/user_all_payload_builder.dart';
|
||||||
|
|
||||||
|
/// 一键跑图:把选中的区域标记为已完成。
|
||||||
|
///
|
||||||
|
/// 依据反编译客户端:
|
||||||
|
/// - 区域进度是 `upsertUserAll.userMapList`,行结构见 `Net/VO/Mai2/UserMap.cs`
|
||||||
|
/// `{mapId, distance, isLock, isClear, isComplete, unlockFlag}`,主键 mapId。
|
||||||
|
/// - `MapMaster.CreateUserDataMapList`(Util/MapMaster.cs:509-543)用 `distance`
|
||||||
|
/// 与该区域的 ReleaseFlag / End 里程针比较,**重算** isClear / isComplete,
|
||||||
|
/// 没有对应针时甚至强制写回 false。所以只发 flag 会被冲掉,必须把
|
||||||
|
/// `distance` 推过 End 针——这里直接给 `UserMapData.MaxDistance`。
|
||||||
|
/// - `unlockFlag` 语义是反的:`IsFinishedOpening ? 0 : 1`。
|
||||||
|
/// - `isLock` 客户端不回读(`ConvertUserMap` 从不读它)。
|
||||||
|
class MapTraversePage extends StatefulWidget {
|
||||||
|
final int userId;
|
||||||
|
final String? cookies;
|
||||||
|
final int? loginDateTime;
|
||||||
|
final int? loginId;
|
||||||
|
final Future<void> Function()? onExitToTitle;
|
||||||
|
|
||||||
|
const MapTraversePage({
|
||||||
|
super.key,
|
||||||
|
required this.userId,
|
||||||
|
this.cookies,
|
||||||
|
this.loginDateTime,
|
||||||
|
this.loginId,
|
||||||
|
this.onExitToTitle,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<MapTraversePage> createState() => _MapTraversePageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
enum _Step { idle, fetchData, upload, logout, complete, failed }
|
||||||
|
|
||||||
|
class _MapTraversePageState extends State<MapTraversePage> {
|
||||||
|
/// 与其它高危功能一致:不写真实成绩,playlog 用同一条占位记录。
|
||||||
|
static const int _placeholderMusicId = 11538;
|
||||||
|
|
||||||
|
final _mapIdController = TextEditingController();
|
||||||
|
final List<int> _pending = [];
|
||||||
|
|
||||||
|
Map<String, Map<String, dynamic>> _userAllData = const {};
|
||||||
|
Set<int>? _serverMapIds;
|
||||||
|
List<Map<String, dynamic>> _serverMaps = const [];
|
||||||
|
|
||||||
|
bool _loading = false;
|
||||||
|
bool _running = false;
|
||||||
|
bool _autoLogout = true;
|
||||||
|
_Step _step = _Step.idle;
|
||||||
|
String _stepMessage = '';
|
||||||
|
|
||||||
|
Timer? _cooldownTimer;
|
||||||
|
int _cooldownRemaining = 0;
|
||||||
|
|
||||||
|
bool get _loggedIn => widget.loginDateTime != null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_syncCooldown();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(covariant MapTraversePage oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (oldWidget.loginDateTime != widget.loginDateTime) {
|
||||||
|
_syncCooldown();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_cooldownTimer?.cancel();
|
||||||
|
_mapIdController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _syncCooldown() {
|
||||||
|
_cooldownTimer?.cancel();
|
||||||
|
final loginDateTime = widget.loginDateTime;
|
||||||
|
if (loginDateTime == null) {
|
||||||
|
setState(() => _cooldownRemaining = 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() => _cooldownRemaining = _computeRemaining(loginDateTime));
|
||||||
|
if (_cooldownRemaining <= 0) return;
|
||||||
|
_cooldownTimer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
final remaining = _computeRemaining(loginDateTime);
|
||||||
|
setState(() => _cooldownRemaining = remaining);
|
||||||
|
if (remaining <= 0) {
|
||||||
|
_cooldownTimer?.cancel();
|
||||||
|
_cooldownTimer = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
int _computeRemaining(int loginDateTime) {
|
||||||
|
final nowSec = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||||
|
final remaining =
|
||||||
|
AppStrings.ticketCooldownSeconds - (nowSec - loginDateTime);
|
||||||
|
return remaining < 0 ? 0 : remaining;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _snack(String message) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _addPending() {
|
||||||
|
final id = int.tryParse(_mapIdController.text.trim()) ?? -1;
|
||||||
|
if (id <= 0) {
|
||||||
|
_snack(AppStrings.mapNeedMapId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_pending.contains(id)) {
|
||||||
|
_snack(AppStrings.listDuplicate);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_pending.add(id);
|
||||||
|
_mapIdController.clear();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _applyServerMaps(List<Map<String, dynamic>> rows) {
|
||||||
|
_serverMaps = rows;
|
||||||
|
_serverMapIds = rows
|
||||||
|
.map((e) => (e['mapId'] as num?)?.toInt() ?? 0)
|
||||||
|
.where((e) => e != 0)
|
||||||
|
.toSet();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _fetchData() async {
|
||||||
|
if (!TitleServerConfigHolder().isConfigured) return;
|
||||||
|
setState(() => _loading = true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final config = TitleServerConfigHolder().config!;
|
||||||
|
final service = TitleApiService(config, cookies: widget.cookies);
|
||||||
|
final results = await Future.wait([
|
||||||
|
service.fetchUserAllData(widget.userId),
|
||||||
|
service.getUserMaps(widget.userId),
|
||||||
|
]);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_userAllData = results[0] as Map<String, Map<String, dynamic>>;
|
||||||
|
_applyServerMaps(results[1] as List<Map<String, dynamic>>);
|
||||||
|
_loading = false;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _loading = false);
|
||||||
|
_snack('${AppStrings.loadFailed}: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _run() async {
|
||||||
|
if (!TitleServerConfigHolder().isConfigured) {
|
||||||
|
_snack(AppStrings.ticketNotConfigured);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final loginDateTime = widget.loginDateTime;
|
||||||
|
final loginId = widget.loginId;
|
||||||
|
if (loginDateTime == null || loginId == null) {
|
||||||
|
_snack(AppStrings.mapNotLoggedIn);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_cooldownRemaining > 0) {
|
||||||
|
_snack(AppStrings.mapCooldownNotice(_cooldownRemaining));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_pending.isEmpty) {
|
||||||
|
_snack(AppStrings.mapNeedMapId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() => _running = true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final config = TitleServerConfigHolder().config!;
|
||||||
|
final service = TitleApiService(config, cookies: widget.cookies);
|
||||||
|
final builder = UserAllPayloadBuilder(config);
|
||||||
|
|
||||||
|
var data = _userAllData;
|
||||||
|
var serverIds = _serverMapIds;
|
||||||
|
if (data.isEmpty || serverIds == null) {
|
||||||
|
_updateStep(_Step.fetchData);
|
||||||
|
final results = await Future.wait([
|
||||||
|
service.fetchUserAllData(widget.userId),
|
||||||
|
service.getUserMaps(widget.userId),
|
||||||
|
]);
|
||||||
|
data = results[0] as Map<String, Map<String, dynamic>>;
|
||||||
|
if (mounted) setState(() => _userAllData = data);
|
||||||
|
_applyServerMaps(results[1] as List<Map<String, dynamic>>);
|
||||||
|
serverIds = _serverMapIds!;
|
||||||
|
}
|
||||||
|
|
||||||
|
_updateStep(_Step.upload);
|
||||||
|
final packet = builder.build(
|
||||||
|
userId: widget.userId,
|
||||||
|
loginId: loginId,
|
||||||
|
loginDateTime: loginDateTime,
|
||||||
|
musicData: _placeholderMusicData(),
|
||||||
|
generalUserInfo: data,
|
||||||
|
);
|
||||||
|
|
||||||
|
// isNewMapList 与 userMapList 一一对齐,每位 '1' 新增 / '0' 更新,
|
||||||
|
// 判据是服务器是否已有同 mapId 的行(VOExtensions.BuildListData)。
|
||||||
|
final maps = [
|
||||||
|
for (final id in _pending) UserAllPayloadBuilder.completedMap(id),
|
||||||
|
];
|
||||||
|
final flags = [
|
||||||
|
for (final id in _pending) serverIds.contains(id) ? '0' : '1',
|
||||||
|
].join();
|
||||||
|
builder.applyMapPatch(packet, maps: maps, newFlags: flags);
|
||||||
|
|
||||||
|
await service.upsertUserAll(packet, widget.userId);
|
||||||
|
_updateStep(_Step.complete, await _verify(service));
|
||||||
|
|
||||||
|
if (_autoLogout && widget.onExitToTitle != null) {
|
||||||
|
await Future.delayed(const Duration(seconds: 2));
|
||||||
|
if (!mounted) return;
|
||||||
|
_updateStep(_Step.logout, AppStrings.exitingToTitle);
|
||||||
|
await widget.onExitToTitle!();
|
||||||
|
}
|
||||||
|
} on TitleApiException catch (e) {
|
||||||
|
_updateStep(_Step.failed, e.message);
|
||||||
|
} catch (e) {
|
||||||
|
_updateStep(_Step.failed, '$e');
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _running = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 回读区分「服务器没写入 userMapList」和「写入了但游戏里仍显示未完成」。
|
||||||
|
Future<String> _verify(TitleApiService service) async {
|
||||||
|
final requested = List<int>.from(_pending);
|
||||||
|
List<Map<String, dynamic>> rows;
|
||||||
|
try {
|
||||||
|
rows = await service.getUserMaps(widget.userId);
|
||||||
|
} catch (e) {
|
||||||
|
return AppStrings.mapVerifyFailed('$e');
|
||||||
|
}
|
||||||
|
if (mounted) setState(() => _applyServerMaps(rows));
|
||||||
|
|
||||||
|
final saved = _serverMapIds ?? <int>{};
|
||||||
|
final missing = requested.where((id) => !saved.contains(id)).toList();
|
||||||
|
if (missing.isNotEmpty) {
|
||||||
|
return AppStrings.mapNotSaved(missing.join(' / '));
|
||||||
|
}
|
||||||
|
return AppStrings.mapVerified(requested.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> _placeholderMusicData() => {
|
||||||
|
'musicId': _placeholderMusicId,
|
||||||
|
'level': 0,
|
||||||
|
'playCount': 1,
|
||||||
|
'achievement': 0,
|
||||||
|
'comboStatus': 0,
|
||||||
|
'syncStatus': 0,
|
||||||
|
'deluxscoreMax': 0,
|
||||||
|
'scoreRank': 0,
|
||||||
|
'extNum1': 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
void _updateStep(_Step 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 (!_loggedIn)
|
||||||
|
_notice(theme, AppStrings.mapNotLoggedIn, error: true),
|
||||||
|
if (_loggedIn && _cooldownRemaining > 0)
|
||||||
|
_notice(theme, AppStrings.mapCooldownNotice(_cooldownRemaining)),
|
||||||
|
_card(
|
||||||
|
theme,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_titleRow(
|
||||||
|
theme,
|
||||||
|
Icons.map_outlined,
|
||||||
|
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(theme),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_runButton(theme),
|
||||||
|
if (_step != _Step.idle) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_progressCard(theme),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _notice(ThemeData theme, String text, {bool error = false}) {
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: error
|
||||||
|
? theme.colorScheme.errorContainer
|
||||||
|
: theme.colorScheme.tertiaryContainer.withValues(alpha: 0.5),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
text,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: error
|
||||||
|
? theme.colorScheme.onErrorContainer
|
||||||
|
: theme.colorScheme.onTertiaryContainer,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _card(ThemeData theme, {required Widget child}) {
|
||||||
|
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: child),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _titleRow(ThemeData theme, IconData icon, String title) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 18, color: theme.colorScheme.primary),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
title,
|
||||||
|
style: theme.textTheme.labelLarge?.copyWith(
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _inputCard(ThemeData theme) {
|
||||||
|
final enabled = !_running;
|
||||||
|
return _card(
|
||||||
|
theme,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: _mapIdController,
|
||||||
|
enabled: enabled,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: AppStrings.mapIdLabel,
|
||||||
|
hintText: AppStrings.mapIdHint,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
contentPadding: const EdgeInsets.all(14),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: enabled ? _addPending : 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(
|
||||||
|
_pending.isEmpty
|
||||||
|
? AppStrings.mapPendingEmpty
|
||||||
|
: _pending.map((e) => '#$e').join(' '),
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
color: _pending.isEmpty
|
||||||
|
? theme.colorScheme.onSurfaceVariant
|
||||||
|
: theme.colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _fetchCard(ThemeData theme) {
|
||||||
|
final hasData = _serverMapIds != null;
|
||||||
|
return _card(
|
||||||
|
theme,
|
||||||
|
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 _autoLogoutToggle(ThemeData theme) {
|
||||||
|
final enabled = widget.onExitToTitle != null && !_running;
|
||||||
|
return _card(
|
||||||
|
theme,
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
onTap: enabled ? () => setState(() => _autoLogout = !_autoLogout) : null,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Checkbox(
|
||||||
|
value: _autoLogout,
|
||||||
|
onChanged: enabled
|
||||||
|
? (v) => setState(() => _autoLogout = v ?? false)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
AppStrings.autoLogoutAndExit,
|
||||||
|
style: theme.textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _runButton(ThemeData theme) {
|
||||||
|
return SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: FilledButton.icon(
|
||||||
|
onPressed: _running || !_loggedIn ? 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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _progressCard(ThemeData theme) {
|
||||||
|
final isFailed = _step == _Step.failed;
|
||||||
|
return _card(
|
||||||
|
theme,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_stepRow(theme, _Step.fetchData, AppStrings.mapStepFetch),
|
||||||
|
_stepRow(theme, _Step.upload, AppStrings.mapStepUpload),
|
||||||
|
if (_autoLogout) _stepRow(theme, _Step.logout, AppStrings.stepLogout),
|
||||||
|
if (_stepMessage.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isFailed
|
||||||
|
? theme.colorScheme.errorContainer
|
||||||
|
: theme.colorScheme.surfaceContainerHighest
|
||||||
|
.withValues(alpha: 0.5),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
_stepMessage,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
color: isFailed
|
||||||
|
? theme.colorScheme.onErrorContainer
|
||||||
|
: theme.colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _stepRow(ThemeData theme, _Step step, String label) {
|
||||||
|
final order = [_Step.fetchData, _Step.upload, _Step.logout, _Step.complete];
|
||||||
|
final current = _step == _Step.failed ? _Step.upload : _step;
|
||||||
|
final done = order.indexOf(current) > order.indexOf(step);
|
||||||
|
final active = _step == step;
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
done
|
||||||
|
? Icons.check_circle
|
||||||
|
: active
|
||||||
|
? Icons.radio_button_checked
|
||||||
|
: Icons.radio_button_unchecked,
|
||||||
|
size: 18,
|
||||||
|
color: done
|
||||||
|
? Colors.green
|
||||||
|
: active
|
||||||
|
? theme.colorScheme.primary
|
||||||
|
: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(child: Text(label, style: theme.textTheme.bodySmall)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,719 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../config/responsive.dart';
|
||||||
|
import '../config/strings.dart';
|
||||||
|
import '../config/title_server_config.dart';
|
||||||
|
import '../services/title_api_service.dart';
|
||||||
|
import '../services/user_all_payload_builder.dart';
|
||||||
|
|
||||||
|
/// 数值修改:Rating 与舞里程。两者的流程一模一样(拉数据 → 改一个数 →
|
||||||
|
/// UpsertUserAll),差别只在改哪个字段,所以合成一个带模式的视图。
|
||||||
|
enum NumberPatchMode {
|
||||||
|
/// 总 Rating:`userData.playerRating` + `userRating.rating`
|
||||||
|
rating,
|
||||||
|
|
||||||
|
/// 舞里程:`userData.point`(余额),可带上 `totalPoint`(累计)
|
||||||
|
maiMile,
|
||||||
|
}
|
||||||
|
|
||||||
|
class NumberPatchPage extends StatelessWidget {
|
||||||
|
final int userId;
|
||||||
|
final String? cookies;
|
||||||
|
final int? loginDateTime;
|
||||||
|
final int? loginId;
|
||||||
|
final Future<void> Function()? onExitToTitle;
|
||||||
|
final NumberPatchMode mode;
|
||||||
|
|
||||||
|
const NumberPatchPage({
|
||||||
|
super.key,
|
||||||
|
required this.userId,
|
||||||
|
this.cookies,
|
||||||
|
this.loginDateTime,
|
||||||
|
this.loginId,
|
||||||
|
this.onExitToTitle,
|
||||||
|
required this.mode,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final isRating = mode == NumberPatchMode.rating;
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(
|
||||||
|
isRating
|
||||||
|
? AppStrings.ratingFeatureTitle
|
||||||
|
: AppStrings.maiMileFeatureTitle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: NumberPatchView(
|
||||||
|
userId: userId,
|
||||||
|
cookies: cookies,
|
||||||
|
loginDateTime: loginDateTime,
|
||||||
|
loginId: loginId,
|
||||||
|
onExitToTitle: onExitToTitle,
|
||||||
|
mode: mode,
|
||||||
|
featureTitle: isRating
|
||||||
|
? AppStrings.ratingFeatureTitle
|
||||||
|
: AppStrings.maiMileFeatureTitle,
|
||||||
|
featureDesc: isRating
|
||||||
|
? AppStrings.ratingFeatureDesc
|
||||||
|
: AppStrings.maiMileFeatureDesc,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum _Step { idle, fetchData, upload, logout, complete, failed }
|
||||||
|
|
||||||
|
class NumberPatchView extends StatefulWidget {
|
||||||
|
final int userId;
|
||||||
|
final String? cookies;
|
||||||
|
|
||||||
|
/// 来自 HomePage 启动时的 UserLoginApi,为 `null` 表示没有有效登录态。
|
||||||
|
final int? loginDateTime;
|
||||||
|
final int? loginId;
|
||||||
|
final Future<void> Function()? onExitToTitle;
|
||||||
|
|
||||||
|
final NumberPatchMode mode;
|
||||||
|
final String featureTitle;
|
||||||
|
final String featureDesc;
|
||||||
|
|
||||||
|
const NumberPatchView({
|
||||||
|
super.key,
|
||||||
|
required this.userId,
|
||||||
|
this.cookies,
|
||||||
|
this.loginDateTime,
|
||||||
|
this.loginId,
|
||||||
|
this.onExitToTitle,
|
||||||
|
required this.mode,
|
||||||
|
required this.featureTitle,
|
||||||
|
required this.featureDesc,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<NumberPatchView> createState() => _NumberPatchViewState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NumberPatchViewState extends State<NumberPatchView> {
|
||||||
|
/// 与解锁/收藏品/旅行伙伴一致:这些操作不写真实成绩,
|
||||||
|
/// playlog 沿用同一条占位记录,Rating 修改需要它来带 before/after。
|
||||||
|
static const int _placeholderMusicId = 11538;
|
||||||
|
|
||||||
|
final _valueController = TextEditingController();
|
||||||
|
|
||||||
|
Map<String, Map<String, dynamic>> _userAllData = const {};
|
||||||
|
bool _maiMileAdd = true;
|
||||||
|
|
||||||
|
bool _loading = false;
|
||||||
|
bool _running = false;
|
||||||
|
bool _autoLogout = true;
|
||||||
|
_Step _step = _Step.idle;
|
||||||
|
String _stepMessage = '';
|
||||||
|
|
||||||
|
Timer? _cooldownTimer;
|
||||||
|
int _cooldownRemaining = 0;
|
||||||
|
|
||||||
|
bool get _isRating => widget.mode == NumberPatchMode.rating;
|
||||||
|
bool get _loggedIn => widget.loginDateTime != null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_syncCooldown();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(covariant NumberPatchView oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (oldWidget.loginDateTime != widget.loginDateTime) {
|
||||||
|
_syncCooldown();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_cooldownTimer?.cancel();
|
||||||
|
_valueController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _syncCooldown() {
|
||||||
|
_cooldownTimer?.cancel();
|
||||||
|
final loginDateTime = widget.loginDateTime;
|
||||||
|
if (loginDateTime == null) {
|
||||||
|
setState(() => _cooldownRemaining = 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() => _cooldownRemaining = _computeRemaining(loginDateTime));
|
||||||
|
if (_cooldownRemaining <= 0) return;
|
||||||
|
_cooldownTimer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
final remaining = _computeRemaining(loginDateTime);
|
||||||
|
setState(() => _cooldownRemaining = remaining);
|
||||||
|
if (remaining <= 0) {
|
||||||
|
_cooldownTimer?.cancel();
|
||||||
|
_cooldownTimer = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
int _computeRemaining(int loginDateTime) {
|
||||||
|
final nowSec = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||||
|
final remaining =
|
||||||
|
AppStrings.ticketCooldownSeconds - (nowSec - loginDateTime);
|
||||||
|
return remaining < 0 ? 0 : remaining;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _snack(String message) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(message)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static int _toInt(dynamic v) {
|
||||||
|
if (v is int) return v;
|
||||||
|
if (v is num) return v.toInt();
|
||||||
|
if (v is String) return int.tryParse(v) ?? 0;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从已拉取的 GetUserDataApi 里读当前值,供「累加」和界面上的当前值展示。
|
||||||
|
int _currentOf(Map<String, Map<String, dynamic>> data, String field) {
|
||||||
|
final userData = data['GetUserDataApi']?['userData'];
|
||||||
|
if (userData is! Map) return 0;
|
||||||
|
return _toInt(userData[field]);
|
||||||
|
}
|
||||||
|
|
||||||
|
int get _currentRating => _currentOf(_userAllData, 'playerRating');
|
||||||
|
int get _currentPoint => _currentOf(_userAllData, 'point');
|
||||||
|
|
||||||
|
/// Rating 的区间是游戏内的 0~99999;舞里程是整个 int32,允许负值
|
||||||
|
/// (累加模式填负数就是扣里程)。
|
||||||
|
int get _min => _isRating ? 0 : UserAllPayloadBuilder.minValue;
|
||||||
|
int get _max => _isRating
|
||||||
|
? UserAllPayloadBuilder.maxRating
|
||||||
|
: UserAllPayloadBuilder.maxValue;
|
||||||
|
|
||||||
|
int _clamp(int v) => v < _min ? _min : (v > _max ? _max : v);
|
||||||
|
|
||||||
|
/// 界面上「将写入」的预览值。未拉取数据时按 0 起算,仅用于提示。
|
||||||
|
int get _previewTarget {
|
||||||
|
final input = int.tryParse(_valueController.text.trim());
|
||||||
|
if (input == null) return 0;
|
||||||
|
if (_isRating) return _clamp(input);
|
||||||
|
return _maiMileAdd ? _clamp(_currentPoint + input) : _clamp(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _checkInput() {
|
||||||
|
final text = _valueController.text.trim();
|
||||||
|
final input = int.tryParse(text);
|
||||||
|
if (input == null) {
|
||||||
|
return _isRating ? AppStrings.ratingNeedValue : AppStrings.maiMileNeedValue;
|
||||||
|
}
|
||||||
|
if (input < _min || input > _max) {
|
||||||
|
return AppStrings.numberPatchOutOfRange(_min, _max);
|
||||||
|
}
|
||||||
|
if (_isRating) return null;
|
||||||
|
if (_maiMileAdd && input == 0) return AppStrings.maiMileNeedValue;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _fetchData() async {
|
||||||
|
if (!TitleServerConfigHolder().isConfigured) return;
|
||||||
|
setState(() => _loading = true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final config = TitleServerConfigHolder().config!;
|
||||||
|
final service = TitleApiService(config, cookies: widget.cookies);
|
||||||
|
final data = await service.fetchUserAllData(widget.userId);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_userAllData = data;
|
||||||
|
_loading = false;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _loading = false);
|
||||||
|
_snack('${AppStrings.loadFailed}: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _run() async {
|
||||||
|
if (!TitleServerConfigHolder().isConfigured) {
|
||||||
|
_snack(AppStrings.ticketNotConfigured);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final loginDateTime = widget.loginDateTime;
|
||||||
|
final loginId = widget.loginId;
|
||||||
|
if (loginDateTime == null || loginId == null) {
|
||||||
|
_snack(AppStrings.numberPatchNotLoggedIn);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_cooldownRemaining > 0) {
|
||||||
|
_snack(AppStrings.numberPatchCooldownNotice(_cooldownRemaining));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final inputError = _checkInput();
|
||||||
|
if (inputError != null) {
|
||||||
|
_snack(inputError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final input = int.parse(_valueController.text.trim());
|
||||||
|
|
||||||
|
setState(() => _running = true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final config = TitleServerConfigHolder().config!;
|
||||||
|
final service = TitleApiService(config, cookies: widget.cookies);
|
||||||
|
final builder = UserAllPayloadBuilder(config);
|
||||||
|
|
||||||
|
var data = _userAllData;
|
||||||
|
if (data.isEmpty) {
|
||||||
|
_updateStep(_Step.fetchData);
|
||||||
|
data = await service.fetchUserAllData(widget.userId);
|
||||||
|
if (mounted) setState(() => _userAllData = data);
|
||||||
|
}
|
||||||
|
|
||||||
|
_updateStep(_Step.upload);
|
||||||
|
final packet = builder.build(
|
||||||
|
userId: widget.userId,
|
||||||
|
loginId: loginId,
|
||||||
|
loginDateTime: loginDateTime,
|
||||||
|
musicData: _placeholderMusicData(),
|
||||||
|
generalUserInfo: data,
|
||||||
|
);
|
||||||
|
|
||||||
|
final String resultMessage;
|
||||||
|
if (_isRating) {
|
||||||
|
builder.applyRatingPatch(packet, rating: input);
|
||||||
|
resultMessage =
|
||||||
|
'${AppStrings.ratingSuccess} → ${_clamp(input)}';
|
||||||
|
} else {
|
||||||
|
final current = _currentOf(data, 'point');
|
||||||
|
final total = _currentOf(data, 'totalPoint');
|
||||||
|
final point = _maiMileAdd ? _clamp(current + input) : _clamp(input);
|
||||||
|
builder.applyMaiMilePatch(
|
||||||
|
packet,
|
||||||
|
point: point,
|
||||||
|
// 累加才动累计值;「直接设定」只覆写余额,累计是历史事实。
|
||||||
|
totalPoint: _maiMileAdd ? _clamp(total + input) : null,
|
||||||
|
);
|
||||||
|
resultMessage = '${AppStrings.maiMileSuccess} → $point';
|
||||||
|
}
|
||||||
|
|
||||||
|
await service.upsertUserAll(packet, widget.userId);
|
||||||
|
_updateStep(_Step.complete, resultMessage);
|
||||||
|
|
||||||
|
if (_autoLogout && widget.onExitToTitle != null) {
|
||||||
|
await Future.delayed(const Duration(seconds: 2));
|
||||||
|
if (!mounted) return;
|
||||||
|
_updateStep(_Step.logout, AppStrings.exitingToTitle);
|
||||||
|
await widget.onExitToTitle!();
|
||||||
|
}
|
||||||
|
} on TitleApiException catch (e) {
|
||||||
|
_updateStep(_Step.failed, e.message);
|
||||||
|
} catch (e) {
|
||||||
|
_updateStep(_Step.failed, '$e');
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _running = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> _placeholderMusicData() => {
|
||||||
|
'musicId': _placeholderMusicId,
|
||||||
|
'level': 0,
|
||||||
|
'playCount': 1,
|
||||||
|
'achievement': 0,
|
||||||
|
'comboStatus': 0,
|
||||||
|
'syncStatus': 0,
|
||||||
|
'deluxscoreMax': 0,
|
||||||
|
'scoreRank': 0,
|
||||||
|
'extNum1': 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
void _updateStep(_Step 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 (!_loggedIn)
|
||||||
|
_notice(theme, AppStrings.numberPatchNotLoggedIn, error: true),
|
||||||
|
if (_loggedIn && _cooldownRemaining > 0)
|
||||||
|
_notice(
|
||||||
|
theme,
|
||||||
|
AppStrings.numberPatchCooldownNotice(_cooldownRemaining),
|
||||||
|
),
|
||||||
|
_descCard(theme),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_inputCard(theme),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_fetchCard(theme),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_autoLogoutToggle(theme),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_runButton(theme),
|
||||||
|
if (_step != _Step.idle) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_progressCard(theme),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _notice(ThemeData theme, String text, {bool error = false}) {
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: error
|
||||||
|
? theme.colorScheme.errorContainer
|
||||||
|
: theme.colorScheme.tertiaryContainer.withValues(alpha: 0.5),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
text,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: error
|
||||||
|
? theme.colorScheme.onErrorContainer
|
||||||
|
: theme.colorScheme.onTertiaryContainer,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _card(ThemeData theme, {required Widget child}) {
|
||||||
|
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: child),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _descCard(ThemeData theme) {
|
||||||
|
return _card(
|
||||||
|
theme,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
_isRating ? Icons.speed : Icons.commute,
|
||||||
|
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,
|
||||||
|
height: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _inputCard(ThemeData theme) {
|
||||||
|
final enabled = !_running;
|
||||||
|
final hasInput = int.tryParse(_valueController.text.trim()) != null;
|
||||||
|
|
||||||
|
return _card(
|
||||||
|
theme,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: _valueController,
|
||||||
|
enabled: enabled,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
onChanged: (_) => setState(() {}),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: _isRating
|
||||||
|
? AppStrings.ratingValueLabel
|
||||||
|
: AppStrings.maiMileValueLabel,
|
||||||
|
hintText: _isRating
|
||||||
|
? AppStrings.ratingValueHint
|
||||||
|
: AppStrings.maiMileValueHint,
|
||||||
|
helperText: AppStrings.numberPatchRangeHint(_min, _max),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
contentPadding: const EdgeInsets.all(14),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (!_isRating) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
DropdownButtonFormField<int>(
|
||||||
|
initialValue: _maiMileAdd ? 1 : 0,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: AppStrings.maiMileModeLabel,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
contentPadding: const EdgeInsets.all(14),
|
||||||
|
),
|
||||||
|
items: [
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: 1,
|
||||||
|
child: Text(
|
||||||
|
AppStrings.maiMileModeAdd,
|
||||||
|
style: theme.textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: 0,
|
||||||
|
child: Text(
|
||||||
|
AppStrings.maiMileModeSet,
|
||||||
|
style: theme.textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onChanged: enabled
|
||||||
|
? (v) => setState(() => _maiMileAdd = (v ?? 1) == 1)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
// 累加要基于服务器上的当前余额,没拉取数据前预览不了,就别猜一个 0。
|
||||||
|
if (hasInput && (_isRating || _userAllData.isNotEmpty)) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
_previewLine,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String get _previewLine {
|
||||||
|
final label = _isRating
|
||||||
|
? AppStrings.ratingCurrentValue
|
||||||
|
: AppStrings.maiMileCurrentBalance;
|
||||||
|
final current = _isRating ? _currentRating : _currentPoint;
|
||||||
|
return AppStrings.numberPatchPreview(label, current, _previewTarget);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _fetchCard(ThemeData theme) {
|
||||||
|
final hasData = _userAllData.isNotEmpty;
|
||||||
|
final current = _isRating ? _currentRating : _currentPoint;
|
||||||
|
final label = _isRating
|
||||||
|
? AppStrings.ratingCurrentValue
|
||||||
|
: AppStrings.maiMileCurrentBalance;
|
||||||
|
|
||||||
|
return _card(
|
||||||
|
theme,
|
||||||
|
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 _autoLogoutToggle(ThemeData theme) {
|
||||||
|
final enabled = widget.onExitToTitle != null && !_running;
|
||||||
|
return _card(
|
||||||
|
theme,
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
onTap: enabled ? () => setState(() => _autoLogout = !_autoLogout) : null,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Checkbox(
|
||||||
|
value: _autoLogout,
|
||||||
|
onChanged: enabled
|
||||||
|
? (v) => setState(() => _autoLogout = v ?? false)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
AppStrings.autoLogoutAndExit,
|
||||||
|
style: theme.textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _runButton(ThemeData theme) {
|
||||||
|
return SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: FilledButton.icon(
|
||||||
|
onPressed: _running || !_loggedIn ? 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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _progressCard(ThemeData theme) {
|
||||||
|
final isFailed = _step == _Step.failed;
|
||||||
|
return _card(
|
||||||
|
theme,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_stepRow(theme, _Step.fetchData, AppStrings.unlockStepFetch),
|
||||||
|
_stepRow(theme, _Step.upload, AppStrings.numberPatchStepUpload),
|
||||||
|
if (_autoLogout)
|
||||||
|
_stepRow(theme, _Step.logout, AppStrings.stepLogout),
|
||||||
|
if (_stepMessage.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isFailed
|
||||||
|
? theme.colorScheme.errorContainer
|
||||||
|
: theme.colorScheme.surfaceContainerHighest
|
||||||
|
.withValues(alpha: 0.5),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
_stepMessage,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
color: isFailed
|
||||||
|
? theme.colorScheme.onErrorContainer
|
||||||
|
: theme.colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _stepRow(ThemeData theme, _Step step, String label) {
|
||||||
|
final order = [_Step.fetchData, _Step.upload, _Step.logout, _Step.complete];
|
||||||
|
final current = _step == _Step.failed ? _Step.upload : _step;
|
||||||
|
final done = order.indexOf(current) > order.indexOf(step);
|
||||||
|
final active = _step == step;
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
done
|
||||||
|
? Icons.check_circle
|
||||||
|
: active
|
||||||
|
? Icons.radio_button_checked
|
||||||
|
: Icons.radio_button_unchecked,
|
||||||
|
size: 18,
|
||||||
|
color: done
|
||||||
|
? Colors.green
|
||||||
|
: active
|
||||||
|
? theme.colorScheme.primary
|
||||||
|
: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(child: Text(label, style: theme.textTheme.bodySmall)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,6 +48,12 @@ class _TravelPartnerPageState extends State<TravelPartnerPage> {
|
|||||||
|
|
||||||
final _grantIdController = TextEditingController();
|
final _grantIdController = TextEditingController();
|
||||||
|
|
||||||
|
/// 每个出战槽位一个等级输入框,留空表示不改该角色的等级。
|
||||||
|
final _slotLevelControllers = List<TextEditingController>.generate(
|
||||||
|
5,
|
||||||
|
(_) => TextEditingController(),
|
||||||
|
);
|
||||||
|
|
||||||
List<UserCharacterBean> _owned = const [];
|
List<UserCharacterBean> _owned = const [];
|
||||||
Map<String, Map<String, dynamic>> _userAllData = const {};
|
Map<String, Map<String, dynamic>> _userAllData = const {};
|
||||||
final List<int> _pendingGrant = [];
|
final List<int> _pendingGrant = [];
|
||||||
@@ -82,6 +88,9 @@ class _TravelPartnerPageState extends State<TravelPartnerPage> {
|
|||||||
void dispose() {
|
void dispose() {
|
||||||
_cooldownTimer?.cancel();
|
_cooldownTimer?.cancel();
|
||||||
_grantIdController.dispose();
|
_grantIdController.dispose();
|
||||||
|
for (final c in _slotLevelControllers) {
|
||||||
|
c.dispose();
|
||||||
|
}
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,9 +201,28 @@ class _TravelPartnerPageState extends State<TravelPartnerPage> {
|
|||||||
}
|
}
|
||||||
setState(() {
|
setState(() {
|
||||||
_slots = normalizeCharaSlot([for (var i = 0; i < 5; i++) leader]);
|
_slots = normalizeCharaSlot([for (var i = 0; i < 5; i++) leader]);
|
||||||
|
// 五个槽位这时都是同一个角色,等级跟着一起复制才对得上。
|
||||||
|
final leaderLevel = _slotLevelControllers[0].text;
|
||||||
|
for (var i = 1; i < 5; i++) {
|
||||||
|
_slotLevelControllers[i].text = leaderLevel;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 槽位上填了的等级 -> characterId -> level。同一角色占多个槽位时取最高的,
|
||||||
|
/// 否则两个槽位填了不同等级就没有确定答案。
|
||||||
|
Map<int, int> _slotLevelsById() {
|
||||||
|
final result = <int, int>{};
|
||||||
|
for (var i = 0; i < 5; i++) {
|
||||||
|
final id = _slots[i];
|
||||||
|
final level = int.tryParse(_slotLevelControllers[i].text.trim());
|
||||||
|
if (id == 0 || level == null) continue;
|
||||||
|
final existing = result[id];
|
||||||
|
if (existing == null || level > existing) result[id] = level;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
String? _validate() {
|
String? _validate() {
|
||||||
if (_pendingGrant.isEmpty && _userAllData.isEmpty) {
|
if (_pendingGrant.isEmpty && _userAllData.isEmpty) {
|
||||||
return AppStrings.travelPartnerNoData;
|
return AppStrings.travelPartnerNoData;
|
||||||
@@ -205,6 +233,11 @@ class _TravelPartnerPageState extends State<TravelPartnerPage> {
|
|||||||
return AppStrings.travelPartnerNotOwnedHint;
|
return AppStrings.travelPartnerNotOwnedHint;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const min = UserAllPayloadBuilder.minCharacterLevel;
|
||||||
|
const max = UserAllPayloadBuilder.maxCharacterLevel;
|
||||||
|
for (final level in _slotLevelsById().values) {
|
||||||
|
if (level < min || level > max) return AppStrings.travelPartnerLevelInvalid;
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,21 +285,51 @@ class _TravelPartnerPageState extends State<TravelPartnerPage> {
|
|||||||
generalUserInfo: data,
|
generalUserInfo: data,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (_pendingGrant.isNotEmpty) {
|
final levels = _slotLevelsById();
|
||||||
// 新角色给最小可用值;已拥有的不重发,避免把 level/awakening 打回。
|
final grantRows = [
|
||||||
|
for (final id in _pendingGrant)
|
||||||
|
{
|
||||||
|
// 新角色默认最小可用值;槽位上填了等级就顺手带上,省一次操作。
|
||||||
|
'characterId': id,
|
||||||
|
'level': UserAllPayloadBuilder.clampCharacterLevel(
|
||||||
|
levels[id] ?? 1,
|
||||||
|
),
|
||||||
|
'awakening': 0,
|
||||||
|
'useCount': 0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
// 已拥有且槽位要求改等级的,发 isNew='0' 的更新行。必须带上原有
|
||||||
|
// useCount / awakening,否则整行被覆盖会把使用次数清零。
|
||||||
|
final ownedById = {for (final c in _owned) c.characterId: c};
|
||||||
|
final levelRows = <Map<String, dynamic>>[];
|
||||||
|
for (final entry in levels.entries) {
|
||||||
|
if (_pendingGrant.contains(entry.key)) continue;
|
||||||
|
final owned = ownedById[entry.key];
|
||||||
|
if (owned == null) continue;
|
||||||
|
final level = UserAllPayloadBuilder.clampCharacterLevel(entry.value);
|
||||||
|
if (level == owned.level) continue;
|
||||||
|
levelRows.add(owned.copyWith(level: level).toWireJson());
|
||||||
|
}
|
||||||
|
if (grantRows.isNotEmpty || levelRows.isNotEmpty) {
|
||||||
builder.applyCharacterListPatch(
|
builder.applyCharacterListPatch(
|
||||||
packet,
|
packet,
|
||||||
characters: [
|
characters: [...grantRows, ...levelRows],
|
||||||
for (final id in _pendingGrant)
|
newFlags: '${'1' * grantRows.length}${'0' * levelRows.length}',
|
||||||
{'characterId': id, 'level': 1, 'awakening': 0, 'useCount': 0},
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
builder.applyCharaSlotPatch(packet, charaSlot: _slots);
|
builder.applyCharaSlotPatch(packet, charaSlot: _slots);
|
||||||
|
|
||||||
await service.upsertUserAll(packet, widget.userId);
|
await service.upsertUserAll(packet, widget.userId);
|
||||||
|
|
||||||
_updateStep(_Step.complete, AppStrings.travelPartnerSuccess);
|
// 两种失败在返回值上完全一样(returnCode 都是 1),只能靠回读区分:
|
||||||
|
// 服务器没写入 userCharacterList,还是写入了但 ID 不在机台 Chara 表里
|
||||||
|
// 被客户端 CharacterSelectProces 静默跳过。
|
||||||
|
_updateStep(
|
||||||
|
_Step.complete,
|
||||||
|
_pendingGrant.isEmpty
|
||||||
|
? AppStrings.travelPartnerSuccess
|
||||||
|
: await _verifyGranted(service),
|
||||||
|
);
|
||||||
|
|
||||||
if (_autoLogout && widget.onExitToTitle != null) {
|
if (_autoLogout && widget.onExitToTitle != null) {
|
||||||
await Future.delayed(const Duration(seconds: 2));
|
await Future.delayed(const Duration(seconds: 2));
|
||||||
@@ -283,6 +346,22 @@ class _TravelPartnerPageState extends State<TravelPartnerPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<String> _verifyGranted(TitleApiService service) async {
|
||||||
|
final requested = List<int>.from(_pendingGrant);
|
||||||
|
List<UserCharacterBean> owned;
|
||||||
|
try {
|
||||||
|
owned = await service.getUserCharacters(widget.userId);
|
||||||
|
} catch (e) {
|
||||||
|
return AppStrings.travelPartnerVerifyFailed('$e');
|
||||||
|
}
|
||||||
|
if (mounted) setState(() => _owned = owned);
|
||||||
|
|
||||||
|
final saved = owned.map((c) => c.characterId).toSet();
|
||||||
|
final missing = requested.where((id) => !saved.contains(id)).toList();
|
||||||
|
if (missing.isEmpty) return AppStrings.travelPartnerVerified(requested.length);
|
||||||
|
return AppStrings.travelPartnerNotSaved(missing.join(' / '));
|
||||||
|
}
|
||||||
|
|
||||||
Map<String, dynamic> _placeholderMusicData() => {
|
Map<String, dynamic> _placeholderMusicData() => {
|
||||||
'musicId': _placeholderMusicId,
|
'musicId': _placeholderMusicId,
|
||||||
'level': 0,
|
'level': 0,
|
||||||
@@ -527,10 +606,48 @@ class _TravelPartnerPageState extends State<TravelPartnerPage> {
|
|||||||
: theme.colorScheme.onSurface,
|
: 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 客户端 `PlInformationProcess.AddDefaultCharacter` 里的默认发放清单,
|
||||||
|
/// 是已知一定存在于机台 Chara 表的那批 ID。
|
||||||
|
static const List<int> _knownCharacterIds = [
|
||||||
|
101, 102, 103, 104, 105,
|
||||||
|
201, 202, 203, 204, 205,
|
||||||
|
301, 302, 303, 304, 305, 306,
|
||||||
|
392, 393, 394, 395,
|
||||||
|
401, 402, 403, 404, 405,
|
||||||
|
501, 502, 503, 504, 505,
|
||||||
|
601, 602, 603, 604, 605,
|
||||||
|
];
|
||||||
|
|
||||||
|
bool _isSuspectId(int id) => !_knownCharacterIds.contains(id);
|
||||||
|
|
||||||
Widget _slotCard(ThemeData theme) {
|
Widget _slotCard(ThemeData theme) {
|
||||||
// 下拉选项 = 已拥有 ∪ 待发放 ∪ 当前槽位已有值。
|
// 下拉选项 = 已拥有 ∪ 待发放 ∪ 当前槽位已有值。
|
||||||
final optionIds = <int>{
|
final optionIds = <int>{
|
||||||
@@ -548,8 +665,11 @@ class _TravelPartnerPageState extends State<TravelPartnerPage> {
|
|||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
for (var i = 0; i < 5; i++)
|
for (var i = 0; i < 5; i++)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 8),
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
child: DropdownButtonFormField<int>(
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
DropdownButtonFormField<int>(
|
||||||
initialValue: _slots[i],
|
initialValue: _slots[i],
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: AppStrings.travelPartnerSlotLabel(i),
|
labelText: AppStrings.travelPartnerSlotLabel(i),
|
||||||
@@ -580,6 +700,30 @@ class _TravelPartnerPageState extends State<TravelPartnerPage> {
|
|||||||
_slots = next;
|
_slots = next;
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
if (_slots[i] != 0) ...[
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
TextField(
|
||||||
|
controller: _slotLevelControllers[i],
|
||||||
|
enabled: !_running,
|
||||||
|
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(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
@@ -599,6 +743,25 @@ class _TravelPartnerPageState extends State<TravelPartnerPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 真实等级和界面等级不是一回事(`UserChara`:显示 = level % 10000、
|
||||||
|
/// 转生 = level ~/ 10000),只在会混淆时才说明。
|
||||||
|
List<Widget> _levelHintLines(ThemeData theme, String raw) {
|
||||||
|
final level = int.tryParse(raw.trim());
|
||||||
|
if (level == null || level < UserAllPayloadBuilder.minCharacterLevel) {
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
if (level <= 9999) return const [];
|
||||||
|
return [
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
AppStrings.travelPartnerLevelConverted(level),
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
Widget _ownedCard(ThemeData theme) {
|
Widget _ownedCard(ThemeData theme) {
|
||||||
return _sectionCard(
|
return _sectionCard(
|
||||||
theme,
|
theme,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import 'package:pointycastle/export.dart';
|
|||||||
import '../config/title_server_config.dart';
|
import '../config/title_server_config.dart';
|
||||||
import '../models/user_data.dart';
|
import '../models/user_data.dart';
|
||||||
import '../models/user_character.dart';
|
import '../models/user_character.dart';
|
||||||
|
import '../models/user_kaleidx_scope.dart';
|
||||||
import '../models/user_preview.dart';
|
import '../models/user_preview.dart';
|
||||||
import '../models/user_rating.dart';
|
import '../models/user_rating.dart';
|
||||||
|
|
||||||
@@ -568,6 +569,46 @@ class TitleApiService {
|
|||||||
);
|
);
|
||||||
return UserCharacterBean.listFromResponse(json);
|
return UserCharacterBean.listFromResponse(json);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 区域进度列表,对应 UserMapResponseVO。
|
||||||
|
///
|
||||||
|
/// 客户端 `PacketGetUserMap.cs:51-57` 是分页拉的:`nextIndex != 0` 就用它续拉,
|
||||||
|
/// 把所有页的 `userMapList` 拼起来。这里照做,否则只能看到第一页的区域。
|
||||||
|
Future<List<Map<String, dynamic>>> getUserMaps(int userId) async {
|
||||||
|
const apiName = 'GetUserMapApi';
|
||||||
|
final all = <Map<String, dynamic>>[];
|
||||||
|
var nextIndex = 0;
|
||||||
|
var pages = 0;
|
||||||
|
|
||||||
|
do {
|
||||||
|
final json = await _callApi(apiName, {
|
||||||
|
'userId': userId,
|
||||||
|
'nextIndex': nextIndex,
|
||||||
|
}, userId);
|
||||||
|
|
||||||
|
final list = json['userMapList'];
|
||||||
|
if (list is List) {
|
||||||
|
all.addAll(list.whereType<Map<String, dynamic>>());
|
||||||
|
}
|
||||||
|
nextIndex = (json['nextIndex'] as num?)?.toInt() ?? 0;
|
||||||
|
pages++;
|
||||||
|
} while (nextIndex != 0 && pages < 20);
|
||||||
|
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 万花宿命之门进度,对应 UserKaleidxScopeResponseVO。
|
||||||
|
///
|
||||||
|
/// 与 `GetUserMapApi` 不同,这个接口不分页(`UserKaleidxScopeRequestVO`
|
||||||
|
/// 只有 `userId` 一个字段),一次拿全。
|
||||||
|
Future<List<UserKaleidxScopeBean>> getUserKaleidxScopes(int userId) async {
|
||||||
|
final json = await _callApi(
|
||||||
|
'GetUserKaleidxScopeApi',
|
||||||
|
{'userId': userId},
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
return UserKaleidxScopeBean.listFromResponse(json);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _RandomHelper {
|
class _RandomHelper {
|
||||||
|
|||||||
@@ -8,6 +8,20 @@ import 'title_api_service.dart';
|
|||||||
/// Builds the UpsertUserAllApi request packet used by the
|
/// Builds the UpsertUserAllApi request packet used by the
|
||||||
/// UpsertMusic (上传成绩) / UnlockMusic (解锁歌曲) workflows.
|
/// UpsertMusic (上传成绩) / UnlockMusic (解锁歌曲) workflows.
|
||||||
class UserAllPayloadBuilder {
|
class UserAllPayloadBuilder {
|
||||||
|
/// `UserDetail` 里这些数值字段在 C# 侧都是 `int`,超出 int32 会让服务器
|
||||||
|
/// 反序列化失败并回 500(同 `playSpecial` 那次)。舞里程直接用整个 int32 区间,
|
||||||
|
/// 允许负值意味着「累加模式填负数就是扣里程」。
|
||||||
|
static const int minValue = -2147483648;
|
||||||
|
static const int maxValue = 2147483647;
|
||||||
|
|
||||||
|
/// 总 Rating 的显示区间,游戏内没有超过 5 位的段位。
|
||||||
|
static const int maxRating = 99999;
|
||||||
|
|
||||||
|
/// `UserChara` 的真实等级上限(`RealLevelMax`)。界面上显示的等级是
|
||||||
|
/// `level % 10000`、转生次数是 `level ~/ 10000`,所以 999999 = 99 转生 + 9999 级。
|
||||||
|
static const int minCharacterLevel = 1;
|
||||||
|
static const int maxCharacterLevel = 999999;
|
||||||
|
|
||||||
final TitleServerConfig config;
|
final TitleServerConfig config;
|
||||||
|
|
||||||
const UserAllPayloadBuilder(this.config);
|
const UserAllPayloadBuilder(this.config);
|
||||||
@@ -30,6 +44,20 @@ class UserAllPayloadBuilder {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static int clampValue(int v) =>
|
||||||
|
v < minValue ? minValue : (v > maxValue ? maxValue : v);
|
||||||
|
|
||||||
|
/// 旅行伙伴等级不允许 0,`UserChara.Clear()` 的初值就是 1。
|
||||||
|
static int clampCharacterLevel(int v) =>
|
||||||
|
v < minCharacterLevel
|
||||||
|
? minCharacterLevel
|
||||||
|
: (v > maxCharacterLevel ? maxCharacterLevel : v);
|
||||||
|
|
||||||
|
static Map<String, dynamic> _userDataOf(Map<String, dynamic> packet) =>
|
||||||
|
((packet['upsertUserAll'] as Map<String, dynamic>)['userData'] as List)
|
||||||
|
.first
|
||||||
|
as Map<String, dynamic>;
|
||||||
|
|
||||||
/// "1.56.00" -> 1056000 (Lionheart convertVersionNumber).
|
/// "1.56.00" -> 1056000 (Lionheart convertVersionNumber).
|
||||||
static int _convertVersionNumber(String version) {
|
static int _convertVersionNumber(String version) {
|
||||||
final parts = version.split('.');
|
final parts = version.split('.');
|
||||||
@@ -369,15 +397,76 @@ class UserAllPayloadBuilder {
|
|||||||
upsert['isNewMusicDetailList'] = '1';
|
upsert['isNewMusicDetailList'] = '1';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 发放/更新旅行伙伴:写 `userCharacterList` 并按行数生成 `isNewCharacterList`。
|
/// 发放/更新旅行伙伴:写 `userCharacterList` 并给出逐行 `isNewCharacterList`。
|
||||||
/// 客户端 BuildListData 只发增量、每位一个字符,所以这里只放要新增的行。
|
///
|
||||||
|
/// [newFlags] 省略时全部按「新增行」发(每位一个 '1')。给已有角色改等级时
|
||||||
|
/// 必须传 '0',否则服务器按插入处理。客户端 `BuildListData` 就是按
|
||||||
|
/// 「服务端是否已有同 characterId 的行」决定这一位是 0 还是 1 的。
|
||||||
void applyCharacterListPatch(
|
void applyCharacterListPatch(
|
||||||
Map<String, dynamic> packet, {
|
Map<String, dynamic> packet, {
|
||||||
required List<Map<String, dynamic>> characters,
|
required List<Map<String, dynamic>> characters,
|
||||||
|
String? newFlags,
|
||||||
}) {
|
}) {
|
||||||
final upsert = packet['upsertUserAll'] as Map<String, dynamic>;
|
final upsert = packet['upsertUserAll'] as Map<String, dynamic>;
|
||||||
upsert['userCharacterList'] = characters;
|
upsert['userCharacterList'] = characters;
|
||||||
upsert['isNewCharacterList'] = List.filled(characters.length, '1').join();
|
upsert['isNewCharacterList'] =
|
||||||
|
newFlags ?? List.filled(characters.length, '1').join();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一键跑图:把区域标记为已完成,并让客户端自己把状态推回「已完成」。
|
||||||
|
///
|
||||||
|
/// `UserMap.isClear` / `isComplete` 在客户端是从 `distance` 派生的
|
||||||
|
/// (`MapMaster.CreateUserDataMapList` 拿 `distance` 与该区域的
|
||||||
|
/// ReleaseFlag / End 里程针比较,没有对应针时甚至强制写回 false),
|
||||||
|
/// 单发 flag 下次进区域选择页就会被冲掉。所以这里把 `distance` 直接推到
|
||||||
|
/// `UserMapData.MaxDistance`,超过任何一张图的 End 针,flag 才站得住。
|
||||||
|
///
|
||||||
|
/// `unlockFlag` 语义是反的:`IsFinishedOpening ? 0 : 1`,取 0 表示已开启。
|
||||||
|
/// `isLock` 客户端不回读(`ConvertUserMap` 从不读它),给 false 只是保持上行一致。
|
||||||
|
///
|
||||||
|
/// [newFlags] 每一位对应 [maps] 里的一行:'1' 新增、'0' 更新,
|
||||||
|
/// 长度必须与 [maps] 一致(`BuildListData` 是一一对齐生成的)。
|
||||||
|
void applyMapPatch(
|
||||||
|
Map<String, dynamic> packet, {
|
||||||
|
required List<Map<String, dynamic>> maps,
|
||||||
|
required String newFlags,
|
||||||
|
}) {
|
||||||
|
assert(maps.length == newFlags.length);
|
||||||
|
final upsert = packet['upsertUserAll'] as Map<String, dynamic>;
|
||||||
|
upsert['userMapList'] = maps;
|
||||||
|
upsert['isNewMapList'] = newFlags;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一行「已完成」的区域记录,字段与 `Net.VO.Mai2.UserMap` 一一对应。
|
||||||
|
static Map<String, dynamic> completedMap(int mapId, {int? distance}) => {
|
||||||
|
'mapId': mapId,
|
||||||
|
'distance': distance ?? maxMapDistance,
|
||||||
|
'isLock': false,
|
||||||
|
'isClear': true,
|
||||||
|
'isComplete': true,
|
||||||
|
'unlockFlag': 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// `UserMapData.MaxDistance = 999999999`,客户端认的最大步数。
|
||||||
|
static const int maxMapDistance = 999999999;
|
||||||
|
|
||||||
|
/// 万花筒:写 `userKaleidxScopeList` + `isNewKaleidxScopeList`。
|
||||||
|
///
|
||||||
|
/// 每行是**整行替换**(15 个字段的 `UserKaleidxScope`),所以调用方必须先把
|
||||||
|
/// 服务器上原行的成绩/日期字段读回来合并,不然 best 记录与 playCount 会被清零。
|
||||||
|
///
|
||||||
|
/// 客户端 `ExportUserAll`(VOExtensions.cs:323)只在数组非空时才给这两个字段赋值,
|
||||||
|
/// 空数组等于什么都没发;[newFlags] 长度必须等于 [scopes] 行数。
|
||||||
|
void applyKaleidxScopePatch(
|
||||||
|
Map<String, dynamic> packet, {
|
||||||
|
required List<Map<String, dynamic>> scopes,
|
||||||
|
required String newFlags,
|
||||||
|
}) {
|
||||||
|
assert(scopes.isNotEmpty);
|
||||||
|
assert(scopes.length == newFlags.length);
|
||||||
|
final upsert = packet['upsertUserAll'] as Map<String, dynamic>;
|
||||||
|
upsert['userKaleidxScopeList'] = scopes;
|
||||||
|
upsert['isNewKaleidxScopeList'] = newFlags;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 编组旅行伙伴:改 `userData[0].charaSlot`(int[5],槽 0 为队长)。
|
/// 编组旅行伙伴:改 `userData[0].charaSlot`(int[5],槽 0 为队长)。
|
||||||
@@ -399,4 +488,55 @@ class UserAllPayloadBuilder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 改总 Rating:只动 `userData.playerRating` 与 `userRating.rating`。
|
||||||
|
///
|
||||||
|
/// `userRating.ratingList` / `newRatingList` 里每首歌各自的 Rating 原样带回,
|
||||||
|
/// 不重排也不改值——服务器就是按这两处的和来出「乐曲 Rating」的。
|
||||||
|
/// playlog 的 before/after 一起对齐,否则存档里那条记录会自相矛盾。
|
||||||
|
void applyRatingPatch(
|
||||||
|
Map<String, dynamic> packet, {
|
||||||
|
required int rating,
|
||||||
|
}) {
|
||||||
|
final userData = _userDataOf(packet);
|
||||||
|
final before = _toInt(userData['playerRating']);
|
||||||
|
final value = rating.clamp(0, maxRating);
|
||||||
|
userData['playerRating'] = value;
|
||||||
|
|
||||||
|
final userRatingList =
|
||||||
|
(packet['upsertUserAll'] as Map<String, dynamic>)['userRatingList']
|
||||||
|
as List;
|
||||||
|
// 整行换掉而不是原地写:userRating 在缺数据时是 `const {}`,改不了。
|
||||||
|
if (userRatingList.first is Map<String, dynamic>) {
|
||||||
|
userRatingList[0] = {
|
||||||
|
...(userRatingList.first as Map<String, dynamic>),
|
||||||
|
'rating': value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final raw in (packet['userPlaylogList'] as List)) {
|
||||||
|
final playlog = raw as Map<String, dynamic>;
|
||||||
|
playlog['beforeRating'] = before;
|
||||||
|
playlog['afterRating'] = value;
|
||||||
|
playlog['beforeDeluxRating'] = before;
|
||||||
|
playlog['afterDeluxRating'] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 改舞里程:`UserDetail.point` 是余额,`totalPoint` 是累计获得量。
|
||||||
|
///
|
||||||
|
/// 客户端只在「获得」时把 point 钳到 99999(`UserDetail.AddMile`),
|
||||||
|
/// 商店扣款走的是不带钳位的 `Point -= cost`,所以直接写更大的值不会被覆回;
|
||||||
|
/// 展示就是一句 `num.ToString()`,没有位数上限。
|
||||||
|
void applyMaiMilePatch(
|
||||||
|
Map<String, dynamic> packet, {
|
||||||
|
required int point,
|
||||||
|
int? totalPoint,
|
||||||
|
}) {
|
||||||
|
final userData = _userDataOf(packet);
|
||||||
|
userData['point'] = clampValue(point);
|
||||||
|
if (totalPoint != null) {
|
||||||
|
userData['totalPoint'] = clampValue(totalPoint);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
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/pages/kaleidx_scope_page.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
Future<void> _pumpPage(WidgetTester tester, {required bool loggedIn}) async {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
await TitleServerConfigHolder().update(
|
||||||
|
const TitleServerConfig(
|
||||||
|
titleServerUrl: 'http://127.0.0.1:9999',
|
||||||
|
aesKey: '0123456789abcdef',
|
||||||
|
aesIv: '0123456789abcdef',
|
||||||
|
clientId: 'A63E01C2805',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
tester.view.physicalSize = const Size(420, 1700);
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: KaleidxScopePage(
|
||||||
|
userId: 42,
|
||||||
|
cookies: 'JSESSIONID=X',
|
||||||
|
loginDateTime: loggedIn ? 1 : null,
|
||||||
|
loginId: loggedIn ? 7 : null,
|
||||||
|
onExitToTitle: () async {},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
}
|
||||||
|
|
||||||
|
Finder _runFinder(WidgetTester tester) => find.ancestor(
|
||||||
|
of: find.text(AppStrings.kaleidxRun),
|
||||||
|
matching: find.byType(FilledButton),
|
||||||
|
);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('渲染门 ID 输入与两个动作勾选,且无溢出', (tester) async {
|
||||||
|
await _pumpPage(tester, loggedIn: true);
|
||||||
|
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
expect(find.text(AppStrings.kaleidxGateIdLabel), findsOneWidget);
|
||||||
|
expect(find.text(AppStrings.kaleidxActionDiscover), findsOneWidget);
|
||||||
|
expect(find.text(AppStrings.kaleidxActionKey), findsOneWidget);
|
||||||
|
// 没勾钥匙时不占版面。
|
||||||
|
expect(find.text(AppStrings.kaleidxKeyNeedsGate), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('勾了钥匙才解释「钥匙离不开门」', (tester) async {
|
||||||
|
await _pumpPage(tester, loggedIn: true);
|
||||||
|
|
||||||
|
await tester.tap(find.text(AppStrings.kaleidxActionKey));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.text(AppStrings.kaleidxKeyNeedsGate), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('添加门 ID 后出现在列表并带状态', (tester) async {
|
||||||
|
await _pumpPage(tester, loggedIn: true);
|
||||||
|
|
||||||
|
await tester.enterText(find.byType(TextField), '3');
|
||||||
|
await tester.tap(find.text(AppStrings.listAddButton));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.textContaining('#3'), findsOneWidget);
|
||||||
|
expect(find.text(AppStrings.kaleidxPendingEmpty), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('两个动作都不勾时被拦下', (tester) async {
|
||||||
|
await _pumpPage(tester, loggedIn: true);
|
||||||
|
|
||||||
|
await tester.enterText(find.byType(TextField), '3');
|
||||||
|
await tester.tap(find.text(AppStrings.listAddButton));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
await tester.tap(find.text(AppStrings.kaleidxActionDiscover));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(_runFinder(tester));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.text(AppStrings.kaleidxNeedAction), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('列表为空时执行被拦下', (tester) async {
|
||||||
|
await _pumpPage(tester, loggedIn: true);
|
||||||
|
|
||||||
|
await tester.tap(_runFinder(tester));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.text(AppStrings.kaleidxNeedGateId), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('未登录时禁用执行', (tester) async {
|
||||||
|
await _pumpPage(tester, loggedIn: false);
|
||||||
|
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
expect(find.text(AppStrings.kaleidxNotLoggedIn), findsOneWidget);
|
||||||
|
|
||||||
|
final run = tester.widget<FilledButton>(_runFinder(tester));
|
||||||
|
expect(run.onPressed, isNull);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
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/pages/map_traverse_page.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
Future<void> _pumpPage(WidgetTester tester, {required bool loggedIn}) async {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
await TitleServerConfigHolder().update(
|
||||||
|
const TitleServerConfig(
|
||||||
|
titleServerUrl: 'http://127.0.0.1:9999',
|
||||||
|
aesKey: '0123456789abcdef',
|
||||||
|
aesIv: '0123456789abcdef',
|
||||||
|
clientId: 'A63E01C2805',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
tester.view.physicalSize = const Size(420, 1600);
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: MapTraversePage(
|
||||||
|
userId: 42,
|
||||||
|
cookies: 'JSESSIONID=X',
|
||||||
|
loginDateTime: loggedIn ? 1 : null,
|
||||||
|
loginId: loggedIn ? 7 : null,
|
||||||
|
onExitToTitle: () async {},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
}
|
||||||
|
|
||||||
|
Finder _runFinder(WidgetTester tester) => find.ancestor(
|
||||||
|
of: find.text(AppStrings.mapRun),
|
||||||
|
matching: find.byType(FilledButton),
|
||||||
|
);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('渲染区域 ID 输入与收藏品限制说明,且无溢出', (tester) async {
|
||||||
|
await _pumpPage(tester, loggedIn: true);
|
||||||
|
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
expect(find.text(AppStrings.mapIdLabel), findsOneWidget);
|
||||||
|
expect(find.text(AppStrings.mapCollectiblesNotice), findsOneWidget);
|
||||||
|
expect(find.text(AppStrings.mapNoData), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('添加区域 ID 后进入待处理列表,重复添加被拒', (tester) async {
|
||||||
|
await _pumpPage(tester, loggedIn: true);
|
||||||
|
|
||||||
|
await tester.enterText(find.byType(TextField), '550001');
|
||||||
|
await tester.tap(find.text(AppStrings.listAddButton));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.text('#550001'), findsOneWidget);
|
||||||
|
expect(find.text(AppStrings.mapNeedMapId), findsNothing);
|
||||||
|
|
||||||
|
await tester.enterText(find.byType(TextField), '550001');
|
||||||
|
await tester.tap(find.text(AppStrings.listAddButton));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.text(AppStrings.listDuplicate), findsOneWidget);
|
||||||
|
expect(find.text('#550001'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('列表为空时执行被拦下', (tester) async {
|
||||||
|
await _pumpPage(tester, loggedIn: true);
|
||||||
|
|
||||||
|
await tester.tap(_runFinder(tester));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.text(AppStrings.mapNeedMapId), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('未登录时禁用执行', (tester) async {
|
||||||
|
await _pumpPage(tester, loggedIn: false);
|
||||||
|
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
expect(find.text(AppStrings.mapNotLoggedIn), findsOneWidget);
|
||||||
|
|
||||||
|
final run = tester.widget<FilledButton>(_runFinder(tester));
|
||||||
|
expect(run.onPressed, isNull);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
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/pages/number_patch_page.dart';
|
||||||
|
import 'package:ichino/services/user_all_payload_builder.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
Future<void> _pumpPage(
|
||||||
|
WidgetTester tester,
|
||||||
|
NumberPatchMode mode, {
|
||||||
|
bool loggedIn = true,
|
||||||
|
}) async {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
await TitleServerConfigHolder().update(
|
||||||
|
const TitleServerConfig(
|
||||||
|
titleServerUrl: 'http://127.0.0.1:9999',
|
||||||
|
aesKey: '0123456789abcdef',
|
||||||
|
aesIv: '0123456789abcdef',
|
||||||
|
clientId: 'A63E01C2805',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
tester.view.physicalSize = const Size(420, 1500);
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: NumberPatchPage(
|
||||||
|
userId: 42,
|
||||||
|
cookies: 'JSESSIONID=X',
|
||||||
|
loginDateTime: loggedIn ? 1 : null,
|
||||||
|
loginId: loggedIn ? 7 : null,
|
||||||
|
onExitToTitle: () async {},
|
||||||
|
mode: mode,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
}
|
||||||
|
|
||||||
|
Finder _runFinder(WidgetTester tester) => find.ancestor(
|
||||||
|
of: find.text(AppStrings.numberPatchRun),
|
||||||
|
matching: find.byType(FilledButton),
|
||||||
|
);
|
||||||
|
|
||||||
|
FilledButton _runButton(WidgetTester tester) =>
|
||||||
|
tester.widget<FilledButton>(_runFinder(tester));
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('Rating 模式渲染总 Rating 输入框且可执行,无溢出', (tester) async {
|
||||||
|
await _pumpPage(tester, NumberPatchMode.rating);
|
||||||
|
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
// 标题在 AppBar 与说明卡各出现一次。
|
||||||
|
expect(find.text(AppStrings.ratingFeatureTitle), findsNWidgets(2));
|
||||||
|
expect(find.text(AppStrings.ratingFeatureDesc), findsOneWidget);
|
||||||
|
expect(find.text(AppStrings.ratingValueLabel), findsOneWidget);
|
||||||
|
// 舞里程才有的「累加 / 直接设定」选择器不该出现。
|
||||||
|
expect(find.text(AppStrings.maiMileModeLabel), findsNothing);
|
||||||
|
expect(_runButton(tester).onPressed, isNotNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('舞里程模式提供累加与设定两种方式', (tester) async {
|
||||||
|
await _pumpPage(tester, NumberPatchMode.maiMile);
|
||||||
|
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
expect(find.text(AppStrings.maiMileModeAdd), findsOneWidget);
|
||||||
|
expect(find.text(AppStrings.maiMileValueLabel), findsOneWidget);
|
||||||
|
|
||||||
|
// 累加要基于服务器上的当前余额,没拉取数据前不猜、不预览。
|
||||||
|
await tester.enterText(find.byType(TextField), '1000');
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.textContaining('→'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('未登录时禁用执行', (tester) async {
|
||||||
|
await _pumpPage(tester, NumberPatchMode.rating, loggedIn: false);
|
||||||
|
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
expect(find.text(AppStrings.numberPatchNotLoggedIn), findsOneWidget);
|
||||||
|
expect(_runButton(tester).onPressed, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('Rating 超过 99999 时被拦下', (tester) async {
|
||||||
|
await _pumpPage(tester, NumberPatchMode.rating);
|
||||||
|
|
||||||
|
await tester.enterText(
|
||||||
|
find.byType(TextField),
|
||||||
|
'${UserAllPayloadBuilder.maxRating + 1}',
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(_runFinder(tester));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
find.text(
|
||||||
|
AppStrings.numberPatchOutOfRange(
|
||||||
|
0,
|
||||||
|
UserAllPayloadBuilder.maxRating,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('舞里程模式接受 int32 两端的大数', (tester) async {
|
||||||
|
await _pumpPage(tester, NumberPatchMode.maiMile);
|
||||||
|
|
||||||
|
for (final value in [
|
||||||
|
UserAllPayloadBuilder.maxValue,
|
||||||
|
UserAllPayloadBuilder.minValue,
|
||||||
|
]) {
|
||||||
|
await tester.enterText(find.byType(TextField), '$value');
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(_runFinder(tester));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
// 不该报越界;会走到拉数据/发包那步,这里只断言没有越界提示。
|
||||||
|
expect(
|
||||||
|
find.text(
|
||||||
|
AppStrings.numberPatchOutOfRange(
|
||||||
|
UserAllPayloadBuilder.minValue,
|
||||||
|
UserAllPayloadBuilder.maxValue,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
findsNothing,
|
||||||
|
reason: '$value 在 int32 区间内,不该被拦下',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('空输入时给出填写提示', (tester) async {
|
||||||
|
await _pumpPage(tester, NumberPatchMode.maiMile);
|
||||||
|
|
||||||
|
await tester.tap(_runFinder(tester));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.text(AppStrings.maiMileNeedValue), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import 'package:flutter_test/flutter_test.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/config/title_server_config.dart';
|
||||||
import 'package:ichino/models/user_character.dart';
|
import 'package:ichino/models/user_character.dart';
|
||||||
|
import 'package:ichino/models/user_kaleidx_scope.dart';
|
||||||
import 'package:ichino/services/user_all_payload_builder.dart';
|
import 'package:ichino/services/user_all_payload_builder.dart';
|
||||||
|
|
||||||
const _config = TitleServerConfig(
|
const _config = TitleServerConfig(
|
||||||
@@ -113,4 +115,396 @@ void main() {
|
|||||||
expect(upsert['isNewItemList'], '11');
|
expect(upsert['isNewItemList'], '11');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('总 Rating patch', () {
|
||||||
|
test('只改 playerRating 与 userRating.rating,每首歌的 ratingList 不动', () {
|
||||||
|
final builder = UserAllPayloadBuilder(_config);
|
||||||
|
final packet = builder.build(
|
||||||
|
userId: 42,
|
||||||
|
loginId: 7,
|
||||||
|
loginDateTime: 1700000000,
|
||||||
|
musicData: const {'musicId': 11538, 'level': 0, 'achievement': 0},
|
||||||
|
generalUserInfo: const {
|
||||||
|
'GetUserDataApi': {
|
||||||
|
'userData': {
|
||||||
|
'playerRating': 12345,
|
||||||
|
'highestRating': 13000,
|
||||||
|
'gradeRating': 5000,
|
||||||
|
'charaSlot': [101],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'GetUserRatingApi': {
|
||||||
|
'userRating': {
|
||||||
|
'rating': 12345,
|
||||||
|
'ratingList': [
|
||||||
|
{'musicId': 834, 'level': 4, 'rating': 100},
|
||||||
|
],
|
||||||
|
'newRatingList': [
|
||||||
|
{'musicId': 11538, 'level': 3, 'rating': 200},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
builder.applyRatingPatch(packet, rating: 99999);
|
||||||
|
|
||||||
|
final upsert = _upsert(packet);
|
||||||
|
final userData = (upsert['userData'] as List).first
|
||||||
|
as Map<String, dynamic>;
|
||||||
|
final userRating = (upsert['userRatingList'] as List).first
|
||||||
|
as Map<String, dynamic>;
|
||||||
|
expect(userData['playerRating'], 99999);
|
||||||
|
expect(userRating['rating'], 99999);
|
||||||
|
|
||||||
|
// 每首歌各自的 Rating 必须原样带回。
|
||||||
|
expect(userRating['ratingList'], [
|
||||||
|
{'musicId': 834, 'level': 4, 'rating': 100},
|
||||||
|
]);
|
||||||
|
expect(userRating['newRatingList'], [
|
||||||
|
{'musicId': 11538, 'level': 3, 'rating': 200},
|
||||||
|
]);
|
||||||
|
// 历史最高、段位 Rating 这些不属于「总 Rating」。
|
||||||
|
expect(userData['highestRating'], 13000);
|
||||||
|
expect(userData['gradeRating'], 5000);
|
||||||
|
|
||||||
|
final playlog = (packet['userPlaylogList'] as List).first
|
||||||
|
as Map<String, dynamic>;
|
||||||
|
expect(playlog['beforeRating'], 12345);
|
||||||
|
expect(playlog['afterRating'], 99999);
|
||||||
|
expect(playlog['beforeDeluxRating'], 12345);
|
||||||
|
expect(playlog['afterDeluxRating'], 99999);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Rating 超过 99999 被钳到上限,负数钳到 0', () {
|
||||||
|
final builder = UserAllPayloadBuilder(_config);
|
||||||
|
final packet = _buildPacket(builder);
|
||||||
|
|
||||||
|
builder.applyRatingPatch(
|
||||||
|
packet,
|
||||||
|
rating: UserAllPayloadBuilder.maxRating + 500,
|
||||||
|
);
|
||||||
|
|
||||||
|
final userData =
|
||||||
|
((_upsert(packet))['userData'] as List).first as Map<String, dynamic>;
|
||||||
|
expect(userData['playerRating'], UserAllPayloadBuilder.maxRating);
|
||||||
|
|
||||||
|
builder.applyRatingPatch(packet, rating: -5);
|
||||||
|
final clamped = (_upsert(packet)['userData'] as List).first
|
||||||
|
as Map<String, dynamic>;
|
||||||
|
expect(clamped['playerRating'], 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GetUserRatingApi 缺数据时不会写到 const 空 map 上崩掉', () {
|
||||||
|
final builder = UserAllPayloadBuilder(_config);
|
||||||
|
final packet = _buildPacket(builder);
|
||||||
|
|
||||||
|
expect(() => builder.applyRatingPatch(packet, rating: 5000), returnsNormally);
|
||||||
|
final userRating = (_upsert(packet))['userRatingList'] as List;
|
||||||
|
expect(userRating.first, {'rating': 5000});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('舞里程 patch', () {
|
||||||
|
test('point 与 totalPoint 分别写入,totalPoint 省略时保持原值', () {
|
||||||
|
final builder = UserAllPayloadBuilder(_config);
|
||||||
|
final packet = builder.build(
|
||||||
|
userId: 42,
|
||||||
|
loginId: 7,
|
||||||
|
loginDateTime: 1700000000,
|
||||||
|
musicData: const {'musicId': 11538, 'level': 0, 'achievement': 0},
|
||||||
|
generalUserInfo: const {
|
||||||
|
'GetUserDataApi': {
|
||||||
|
'userData': {'point': 8000, 'totalPoint': 120000},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
builder.applyMaiMilePatch(packet, point: 99999);
|
||||||
|
|
||||||
|
final userData =
|
||||||
|
(_upsert(packet))['userData'] as List;
|
||||||
|
final row = userData.first as Map<String, dynamic>;
|
||||||
|
expect(row['point'], 99999);
|
||||||
|
expect(row['totalPoint'], 120000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('累加越过 int32 上界时被钳住', () {
|
||||||
|
const current = 2147483000;
|
||||||
|
final builder = UserAllPayloadBuilder(_config);
|
||||||
|
final packet = builder.build(
|
||||||
|
userId: 42,
|
||||||
|
loginId: 7,
|
||||||
|
loginDateTime: 1700000000,
|
||||||
|
musicData: const {'musicId': 11538, 'level': 0, 'achievement': 0},
|
||||||
|
generalUserInfo: const {
|
||||||
|
'GetUserDataApi': {
|
||||||
|
'userData': {'point': current, 'totalPoint': current},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// 累加 2000 会越过 int32 上界,应被钳住。
|
||||||
|
builder.applyMaiMilePatch(
|
||||||
|
packet,
|
||||||
|
point: UserAllPayloadBuilder.clampValue(current + 2000),
|
||||||
|
totalPoint: UserAllPayloadBuilder.clampValue(current + 2000),
|
||||||
|
);
|
||||||
|
|
||||||
|
final row = ((_upsert(packet))['userData'] as List).first
|
||||||
|
as Map<String, dynamic>;
|
||||||
|
expect(row['point'], UserAllPayloadBuilder.maxValue);
|
||||||
|
expect(row['totalPoint'], UserAllPayloadBuilder.maxValue);
|
||||||
|
expect(UserAllPayloadBuilder.maxValue, 2147483647);
|
||||||
|
expect(UserAllPayloadBuilder.minValue, -2147483648);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('负数舞里程原样写入,只有越过 int32 下界才钳', () {
|
||||||
|
final builder = UserAllPayloadBuilder(_config);
|
||||||
|
final packet = builder.build(
|
||||||
|
userId: 42,
|
||||||
|
loginId: 7,
|
||||||
|
loginDateTime: 1700000000,
|
||||||
|
musicData: const {'musicId': 11538, 'level': 0, 'achievement': 0},
|
||||||
|
generalUserInfo: const {
|
||||||
|
'GetUserDataApi': {
|
||||||
|
'userData': {'point': 5000, 'totalPoint': 5000},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// 累加模式下填负数就是扣里程,不该被当成非法值抬回 0。
|
||||||
|
builder.applyMaiMilePatch(packet, point: -7);
|
||||||
|
|
||||||
|
final row = (_upsert(packet)['userData'] as List).first
|
||||||
|
as Map<String, dynamic>;
|
||||||
|
expect(row['point'], -7);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clampValue 的两侧边界', () {
|
||||||
|
expect(UserAllPayloadBuilder.clampValue(0), 0);
|
||||||
|
expect(UserAllPayloadBuilder.clampValue(-7), -7);
|
||||||
|
expect(
|
||||||
|
UserAllPayloadBuilder.clampValue(UserAllPayloadBuilder.maxValue + 1),
|
||||||
|
UserAllPayloadBuilder.maxValue,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
UserAllPayloadBuilder.clampValue(UserAllPayloadBuilder.minValue - 1),
|
||||||
|
UserAllPayloadBuilder.minValue,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('旅行伙伴等级 patch', () {
|
||||||
|
test('新增行标 1、改等级行标 0,两段拼成 isNewCharacterList', () {
|
||||||
|
final builder = UserAllPayloadBuilder(_config);
|
||||||
|
final packet = _buildPacket(builder);
|
||||||
|
|
||||||
|
builder.applyCharacterListPatch(
|
||||||
|
packet,
|
||||||
|
characters: [
|
||||||
|
{'characterId': 101, 'level': 999999, 'awakening': 0, 'useCount': 0},
|
||||||
|
{'characterId': 102, 'level': 5000, 'awakening': 3, 'useCount': 77},
|
||||||
|
],
|
||||||
|
newFlags: '10',
|
||||||
|
);
|
||||||
|
|
||||||
|
final upsert = _upsert(packet);
|
||||||
|
expect(upsert['isNewCharacterList'], '10');
|
||||||
|
expect(upsert['userCharacterList'], hasLength(2));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('省略 newFlags 时沿用全 1 的新增语义', () {
|
||||||
|
final builder = UserAllPayloadBuilder(_config);
|
||||||
|
final packet = _buildPacket(builder);
|
||||||
|
|
||||||
|
builder.applyCharacterListPatch(packet, characters: [
|
||||||
|
{'characterId': 101, 'level': 1, 'awakening': 0, 'useCount': 0},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(_upsert(packet)['isNewCharacterList'], '1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('等级钳位:0 以下抬到 1,超过真实等级上限压到 999999', () {
|
||||||
|
expect(UserAllPayloadBuilder.clampCharacterLevel(0), 1);
|
||||||
|
expect(UserAllPayloadBuilder.clampCharacterLevel(-9), 1);
|
||||||
|
expect(UserAllPayloadBuilder.clampCharacterLevel(10000000), 999999);
|
||||||
|
expect(UserAllPayloadBuilder.clampCharacterLevel(999999), 999999);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('一键跑图 userMapList patch', () {
|
||||||
|
test('完成行的字段与 Net.VO.Mai2.UserMap 一一对应', () {
|
||||||
|
final row = UserAllPayloadBuilder.completedMap(550001);
|
||||||
|
|
||||||
|
// unlockFlag 语义是反的:0 才表示已开启(IsFinishedOpening)。
|
||||||
|
expect(row.keys.toSet(), {
|
||||||
|
'mapId',
|
||||||
|
'distance',
|
||||||
|
'isLock',
|
||||||
|
'isClear',
|
||||||
|
'isComplete',
|
||||||
|
'unlockFlag',
|
||||||
|
});
|
||||||
|
expect(row['mapId'], 550001);
|
||||||
|
expect(row['isClear'], isTrue);
|
||||||
|
expect(row['isComplete'], isTrue);
|
||||||
|
expect(row['unlockFlag'], 0);
|
||||||
|
// distance 必须推过 End 针,否则客户端会用 distance 把 flag 冲回 false。
|
||||||
|
expect(row['distance'], UserAllPayloadBuilder.maxMapDistance);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isNewMapList 每位对应一行,服务器已有的行标 0', () {
|
||||||
|
final builder = UserAllPayloadBuilder(_config);
|
||||||
|
final packet = _buildPacket(builder);
|
||||||
|
|
||||||
|
builder.applyMapPatch(
|
||||||
|
packet,
|
||||||
|
maps: [
|
||||||
|
UserAllPayloadBuilder.completedMap(1),
|
||||||
|
UserAllPayloadBuilder.completedMap(2),
|
||||||
|
UserAllPayloadBuilder.completedMap(3),
|
||||||
|
],
|
||||||
|
newFlags: '010',
|
||||||
|
);
|
||||||
|
|
||||||
|
final upsert = _upsert(packet);
|
||||||
|
expect(upsert['userMapList'], hasLength(3));
|
||||||
|
expect(upsert['isNewMapList'], '010');
|
||||||
|
expect(upsert['isNewMapList'].length,
|
||||||
|
(upsert['userMapList'] as List).length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('可以单独抬高 distance 而不动其它字段', () {
|
||||||
|
final row = UserAllPayloadBuilder.completedMap(9, distance: 12345);
|
||||||
|
expect(row['distance'], 12345);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('万花筒 userKaleidxScopeList patch', () {
|
||||||
|
test('wire 行的 15 个字段与 UserKaleidxScope 结构一致', () {
|
||||||
|
final row = const UserKaleidxScopeBean(gateId: 3).toWireJson();
|
||||||
|
|
||||||
|
// totalDeluxscore 的拼写是原样的 Deluxscore,写错服务器认不出。
|
||||||
|
expect(row.keys.toList(), [
|
||||||
|
'gateId',
|
||||||
|
'isGateFound',
|
||||||
|
'isKeyFound',
|
||||||
|
'isClear',
|
||||||
|
'totalRestLife',
|
||||||
|
'totalAchievement',
|
||||||
|
'totalDeluxscore',
|
||||||
|
'bestAchievement',
|
||||||
|
'bestDeluxscore',
|
||||||
|
'bestAchievementDate',
|
||||||
|
'bestDeluxscoreDate',
|
||||||
|
'playCount',
|
||||||
|
'clearDate',
|
||||||
|
'lastPlayDate',
|
||||||
|
'isInfoWatched',
|
||||||
|
]);
|
||||||
|
// 日期字段用 "" 而不是 null,对齐客户端 class 构造器的默认值。
|
||||||
|
expect(row['bestAchievementDate'], '');
|
||||||
|
expect(row['clearDate'], '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isNewKaleidxScopeList 位数与行数一致,已有行标 0', () {
|
||||||
|
final builder = UserAllPayloadBuilder(_config);
|
||||||
|
final packet = _buildPacket(builder);
|
||||||
|
|
||||||
|
final scopes = [
|
||||||
|
const UserKaleidxScopeBean(gateId: 1, isGateFound: true),
|
||||||
|
const UserKaleidxScopeBean(gateId: 2, isGateFound: true),
|
||||||
|
];
|
||||||
|
builder.applyKaleidxScopePatch(
|
||||||
|
packet,
|
||||||
|
scopes: [for (final s in scopes) s.toWireJson()],
|
||||||
|
newFlags: '01',
|
||||||
|
);
|
||||||
|
|
||||||
|
final upsert = _upsert(packet);
|
||||||
|
expect(upsert['userKaleidxScopeList'], hasLength(2));
|
||||||
|
expect(upsert['isNewKaleidxScopeList'], '01');
|
||||||
|
expect(
|
||||||
|
(upsert['isNewKaleidxScopeList'] as String).length,
|
||||||
|
(upsert['userKaleidxScopeList'] as List).length,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('新发现门:isGateFound=true,给钥匙时连带把门标为已发现', () {
|
||||||
|
final foundOnly = UserKaleidxScopeBean.discovered(
|
||||||
|
7,
|
||||||
|
giveKey: false,
|
||||||
|
).toWireJson();
|
||||||
|
expect(foundOnly['isGateFound'], isTrue);
|
||||||
|
expect(foundOnly['isKeyFound'], isFalse);
|
||||||
|
|
||||||
|
final withKey = UserKaleidxScopeBean.discovered(
|
||||||
|
7,
|
||||||
|
giveKey: true,
|
||||||
|
).toWireJson();
|
||||||
|
expect(withKey['isKeyFound'], isTrue);
|
||||||
|
expect(withKey['isGateFound'], isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('已有行只叠加布尔位,成绩与日期不被清零', () {
|
||||||
|
final server = UserKaleidxScopeBean.fromJson(const {
|
||||||
|
'gateId': 4,
|
||||||
|
'isGateFound': true,
|
||||||
|
'isKeyFound': false,
|
||||||
|
'isClear': false,
|
||||||
|
'totalRestLife': 5,
|
||||||
|
'totalAchievement': 612345,
|
||||||
|
'totalDeluxscore': 987,
|
||||||
|
'bestAchievement': 1012345,
|
||||||
|
'bestDeluxscore': 55,
|
||||||
|
'bestAchievementDate': '2026-01-02 03:04:05.0',
|
||||||
|
'bestDeluxscoreDate': '2026-01-02 03:04:05.0',
|
||||||
|
'playCount': 12,
|
||||||
|
'clearDate': '',
|
||||||
|
'lastPlayDate': '2026-09-01 00:00:00.0',
|
||||||
|
'isInfoWatched': true,
|
||||||
|
});
|
||||||
|
|
||||||
|
final updated = server
|
||||||
|
.withActions(discover: false, giveKey: true)
|
||||||
|
.toWireJson();
|
||||||
|
|
||||||
|
expect(updated['isKeyFound'], isTrue);
|
||||||
|
expect(updated['isGateFound'], isTrue);
|
||||||
|
expect(updated['bestAchievement'], 1012345);
|
||||||
|
expect(updated['bestAchievementDate'], '2026-01-02 03:04:05.0');
|
||||||
|
expect(updated['playCount'], 12);
|
||||||
|
expect(updated['totalRestLife'], 5);
|
||||||
|
expect(updated['isInfoWatched'], isTrue);
|
||||||
|
expect(updated['lastPlayDate'], '2026-09-01 00:00:00.0');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('withActions 不会把已经为 true 的位改回 false', () {
|
||||||
|
const cleared = UserKaleidxScopeBean(
|
||||||
|
gateId: 9,
|
||||||
|
isGateFound: true,
|
||||||
|
isKeyFound: true,
|
||||||
|
isClear: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
final again = cleared.withActions(discover: true, giveKey: false);
|
||||||
|
|
||||||
|
expect(again.isGateFound, isTrue);
|
||||||
|
expect(again.isKeyFound, isTrue);
|
||||||
|
expect(again.isClear, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('状态文案覆盖四种组合,隐形态单独说明', () {
|
||||||
|
expect(AppStrings.kaleidxGateState(false, false, false), '未见过');
|
||||||
|
expect(AppStrings.kaleidxGateState(true, false, false), '已发现 · 未解锁');
|
||||||
|
expect(AppStrings.kaleidxGateState(true, true, false), '可挑战');
|
||||||
|
expect(AppStrings.kaleidxGateState(true, true, true), '已通关');
|
||||||
|
// !found && key 是隐形,不是「锁着的门」。
|
||||||
|
expect(
|
||||||
|
AppStrings.kaleidxGateState(false, true, false),
|
||||||
|
contains('隐形'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user