feat: 旅行伙伴发放与编组、修复退登卡死、优化宽屏布局、加 CI
自动退出登录卡死:_performLogoutAndExit 只 pop() 一层,而解锁/收藏品页是 push
在 HomePage 之上的,退登后被弹回「风险」hub,同时 _loggingOut 仍为 true,
PopScope(canPop:false) + 返回键转圈形成死局。改为 popUntil(isFirst)。
旅行伙伴发不了:它是 ItemKind.Character = 9,与搭档 Partner = 10 是两种东西,
原先收藏品的类型列表 [1,2,3,10,11,12] 里根本没有 9。按客户端实现,角色不走
userItemList(ExportUserItems 不导出 itemKind 9),而是
upsertUserAll.userCharacterList 的 {characterId, level, awakening, useCount},
配 isNewCharacterList 一行一字符。新增发放与 5 槽 charaSlot 编组页,
含「把槽 0 复制到其他槽位」,并校验所选角色确为已拥有或本次待发放。
宽屏布局:各页只有 ConstrainedBox 限宽、没有居中容器,1440 窗口下内容贴左、
右侧大片空白。抽出 responsiveBody 统一居中;B50 预览改用按宽度适配
(InteractiveViewer constrained:false + FittedBox fitWidth),横屏下不再被
contain 缩成中间一小块,导出仍是原始 1762x1850。
CI:.github/workflows/build.yml 先跑 analyze+test 作为门槛,再产出
Windows exe、Android apk、iOS 未签名 ipa(macOS runner,无证书故不可安装)。
README 从 Flutter 模板改写为实际文档,含 path_provider/jni 与 JBR jlink
两个构建环境坑的说明。
This commit is contained in:
1 parent
ebd1fdddaa
commit
8dda399f76
24 files changed
+1832
-134
No files matched your search
@@ -0,0 +1,100 @@
|
|||||||
|
name: build
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
tags: ["v*"]
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: build-${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
checks:
|
||||||
|
name: Analyze & test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
channel: stable
|
||||||
|
cache: true
|
||||||
|
- run: flutter pub get
|
||||||
|
- run: flutter analyze
|
||||||
|
- run: flutter test
|
||||||
|
|
||||||
|
android:
|
||||||
|
name: Android APK
|
||||||
|
needs: checks
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: "17"
|
||||||
|
- uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
channel: stable
|
||||||
|
cache: true
|
||||||
|
- run: flutter pub get
|
||||||
|
- run: flutter build apk --release
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ichino-android-apk
|
||||||
|
path: build/app/outputs/flutter-apk/app-release.apk
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
windows:
|
||||||
|
name: Windows exe
|
||||||
|
needs: checks
|
||||||
|
runs-on: windows-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
channel: stable
|
||||||
|
cache: true
|
||||||
|
- run: flutter pub get
|
||||||
|
- run: flutter build windows --release
|
||||||
|
- name: Package runner output
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
Compress-Archive -Path build\windows\x64\runner\Release\* `
|
||||||
|
-DestinationPath ichino-windows-x64.zip
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ichino-windows-exe
|
||||||
|
path: ichino-windows-x64.zip
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
ios:
|
||||||
|
name: iOS unsigned ipa
|
||||||
|
needs: checks
|
||||||
|
runs-on: macos-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
channel: stable
|
||||||
|
cache: true
|
||||||
|
- run: flutter pub get
|
||||||
|
# 无证书/描述文件,只能出未签名包:可验证能编译,不能直接装机。
|
||||||
|
- run: flutter build ios --release --no-codesign
|
||||||
|
- name: Package unsigned .ipa
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
cd build/ios/iphoneos
|
||||||
|
mkdir -p Payload
|
||||||
|
cp -R Runner.app Payload/
|
||||||
|
zip -qry "$GITHUB_WORKSPACE/ichino-ios-unsigned.ipa" Payload
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ichino-ios-unsigned-ipa
|
||||||
|
path: ichino-ios-unsigned.ipa
|
||||||
|
if-no-files-found: error
|
||||||
@@ -43,3 +43,6 @@ app.*.map.json
|
|||||||
/android/app/debug
|
/android/app/debug
|
||||||
/android/app/profile
|
/android/app/profile
|
||||||
/android/app/release
|
/android/app/release
|
||||||
|
|
||||||
|
# Flutter DevTools 本地配置
|
||||||
|
devtools_options.yaml
|
||||||
@@ -1,17 +1,104 @@
|
|||||||
# Ichino
|
# Ichino
|
||||||
|
|
||||||
A new Flutter project.
|
基于 QR Code 登录的 maimai DX 国服第三方客户端,Flutter 实现,覆盖
|
||||||
|
Android / iOS / Web / Windows / macOS / Linux。
|
||||||
|
|
||||||
## Getting Started
|
包名 `ichino`,应用标识 `dev.naominet.ichino`。
|
||||||
|
|
||||||
This project is a starting point for a Flutter application.
|
> ⚠️ 「风险」页里的功能会向服务器发送伪造的 `UpsertUserAllApi` 数据包,属于高危操作,
|
||||||
|
> 可能封号。请自行评估风险。
|
||||||
|
|
||||||
A few resources to get you started if this is your first Flutter project:
|
## 功能
|
||||||
|
|
||||||
- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter)
|
| 入口 | 说明 |
|
||||||
- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
|
| --- | --- |
|
||||||
- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources)
|
| 主页 | Aime 登录(QR / 令牌)、Rating 详情、入坑信息、游玩统计、状态 |
|
||||||
|
| 票据 | 功能票查询与使用 |
|
||||||
|
| B50 | Best 50 成绩图,可导出 PNG(移植自 Empurple 的 `Best50ImageRenderer`) |
|
||||||
|
| 风险 | 歌曲解锁、收藏品获取、旅行伙伴发放与编组 |
|
||||||
|
| 设置 | Title Server / Auth Server / 机器参数,支持配置导入导出 |
|
||||||
|
|
||||||
For help getting started with Flutter development, view the
|
## 开发
|
||||||
[online documentation](https://docs.flutter.dev/), which offers tutorials,
|
|
||||||
samples, guidance on mobile development, and a full API reference.
|
```bash
|
||||||
|
flutter pub get
|
||||||
|
flutter run # 默认设备
|
||||||
|
flutter run -d chrome # Web
|
||||||
|
flutter analyze # 需保持 0 error/warning
|
||||||
|
flutter test
|
||||||
|
```
|
||||||
|
|
||||||
|
构建产物:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flutter build apk --release # Android
|
||||||
|
flutter build windows --release # Windows(产物为 ichino.exe)
|
||||||
|
flutter build macos --release # macOS
|
||||||
|
flutter build linux --release # Linux
|
||||||
|
flutter build web --release # Web
|
||||||
|
flutter build ios --release --no-codesign # iOS(无证书时只验证可编译)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
lib/
|
||||||
|
├── main.dart # 入口 + 登录页
|
||||||
|
├── config/ # 字符串、响应式布局、Title Server 配置
|
||||||
|
├── models/ # API 数据模型(UserData / UserPreview / UserRating / UserCharacter)
|
||||||
|
├── pages/ # 各功能页面
|
||||||
|
├── services/ # 传输层、乐曲元数据、导出、RA 计算
|
||||||
|
└── widgets/ # 可复用视觉组件(B50 海报)
|
||||||
|
```
|
||||||
|
|
||||||
|
平台相关代码用条件导入分离,形如 `xxx_service.dart` + `xxx_service_native.dart`
|
||||||
|
+ `xxx_service_web.dart`(见 `qr_service`、`file_picker_service`、
|
||||||
|
`png_export_service`、`music_data_cache`)。
|
||||||
|
|
||||||
|
## 关键实现说明
|
||||||
|
|
||||||
|
- **传输层**:`TitleApiService` 负责 `MD5(apiName + salt + obfuscateParam)` 生成 URL 路径、
|
||||||
|
JSON → ZLib → AES-CBC 编解码、`Set-Cookie` 会话维持。时间戳用 JST(UTC+9)。
|
||||||
|
- **B50 成绩图**:底图 `assets/b50/base.png`(1762×1850)之上按固定像素坐标叠加头像、
|
||||||
|
昵称、50 张卡片与页脚;卡片 300×100、每行 5 张,BEST35 从 y=350 起、BEST15 固定从
|
||||||
|
y=1250 起。RA 与评级由 `RatingCalculator` 计算,曲名与定数取自 diving-fish 的
|
||||||
|
`music_data`。导出走 `RepaintBoundary`,预览缩放不影响导出分辨率。
|
||||||
|
- **旅行伙伴 ≠ 搭档**:旅行伙伴是 `ItemKind.Character = 9`,走
|
||||||
|
`upsertUserAll.userCharacterList`(`{characterId, level, awakening, useCount}`);
|
||||||
|
搭档是 `ItemKind.Partner = 10`,走 `userItemList`。出战编组是
|
||||||
|
`UserDetail.charaSlot`(`int[5]`,槽 0 为队长)。
|
||||||
|
|
||||||
|
## 构建环境注意事项
|
||||||
|
|
||||||
|
以下问题在本仓库已确认,改动依赖或升级 Flutter 时请留意:
|
||||||
|
|
||||||
|
1. **`path_provider_android` 必须钉在 2.3.0 以下**(见 `pubspec.yaml` 的
|
||||||
|
`dependency_overrides`)。2.3.x 起它依赖 `jni`,而 `jni` 把 `windows`/`linux`
|
||||||
|
也声明成 `ffiPlugin`,只要它出现在依赖树里,桌面构建就会去链接 `jvm.lib` 并失败。
|
||||||
|
`share_plus` 间接依赖 `path_provider`,所以不能靠去掉直接依赖解决。
|
||||||
|
2. **命令行构建请显式指定 JDK**。Android Studio 常驻的 Gradle daemon 用它自带的 JBR,
|
||||||
|
而 JBR 不含 `jlink.exe`,会导致
|
||||||
|
`:flutter_plugin_android_lifecycle:compileReleaseJavaWithJavac` 失败。
|
||||||
|
在 `~/.gradle/gradle.properties` 里设置
|
||||||
|
`org.gradle.java.home=<一个完整 JDK>` 即可。判据:`--no-daemon` 能过而带 daemon 不过。
|
||||||
|
3. **Release APK 目前使用 debug 签名**(`android/app/build.gradle.kts` 的
|
||||||
|
`signingConfig`),产物可直接安装,但不是正式发布签名。
|
||||||
|
|
||||||
|
## CI
|
||||||
|
|
||||||
|
`.github/workflows/build.yml` 在 push / PR / 手动触发时执行:
|
||||||
|
|
||||||
|
- `checks`:`flutter analyze` + `flutter test`,作为其余作业的前置门槛
|
||||||
|
- `android`:`app-release.apk`
|
||||||
|
- `windows`:`ichino-windows-x64.zip`(含 `ichino.exe` 及运行所需 dll/assets)
|
||||||
|
- `ios`:`ichino-ios-unsigned.ipa`
|
||||||
|
|
||||||
|
产物以 artifact 形式上传。**注意 iOS 包是未签名的**:GitHub 托管的 macOS runner 没有
|
||||||
|
证书与描述文件,该 ipa 只能用于验证编译,无法直接安装到设备。要出可安装的 ipa,需要在
|
||||||
|
仓库 Secrets 中配置签名证书并改用 `--codesign`。
|
||||||
|
|
||||||
|
## 致谢
|
||||||
|
|
||||||
|
- 图标与封面资源:assets2.lxns.net
|
||||||
|
- 乐曲定数与曲名:diving-fish
|
||||||
|
- B50 版面与本作 UI:chuxuehaocai
|
||||||
@@ -23,6 +23,18 @@ bool isTablet(BuildContext context) =>
|
|||||||
bool isDesktop(BuildContext context) =>
|
bool isDesktop(BuildContext context) =>
|
||||||
MediaQuery.of(context).size.width >= _breakpointDesktop;
|
MediaQuery.of(context).size.width >= _breakpointDesktop;
|
||||||
|
|
||||||
|
/// 把页面内容限制在响应式宽度内,并在宽窗口(桌面/横屏)下水平居中。
|
||||||
|
/// 之前各页只有 ConstrainedBox 限宽,没有居中容器,1440 宽的窗口里内容
|
||||||
|
/// 会贴左、右侧留出大片空白。
|
||||||
|
Widget responsiveBody(BuildContext context, {required Widget child}) {
|
||||||
|
return Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(maxWidth: responsiveMaxWidth(context)),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Build columns for tablet/desktop — on phone, each item is full-width;
|
/// Build columns for tablet/desktop — on phone, each item is full-width;
|
||||||
/// on tablet+ they are laid out in [columns] columns.
|
/// on tablet+ they are laid out in [columns] columns.
|
||||||
List<Widget> responsiveGrid({
|
List<Widget> responsiveGrid({
|
||||||
|
|||||||
+46
-1
@@ -174,6 +174,19 @@ class AppStrings {
|
|||||||
static const unlockNeedMusicId = '请填写要解锁的歌曲 ID。';
|
static const unlockNeedMusicId = '请填写要解锁的歌曲 ID。';
|
||||||
static const unlockNeedOption = '请至少勾选一项解锁内容。';
|
static const unlockNeedOption = '请至少勾选一项解锁内容。';
|
||||||
static const unlockNotLoggedIn = '尚未登录游戏服务器,无法执行解锁操作。';
|
static const unlockNotLoggedIn = '尚未登录游戏服务器,无法执行解锁操作。';
|
||||||
|
|
||||||
|
// 待提交列表 (userItemList 可累加多行)
|
||||||
|
static const listAddButton = '添加到列表';
|
||||||
|
static const listRemoveTooltip = '从列表移除';
|
||||||
|
static const listDuplicate = '该内容已在列表中。';
|
||||||
|
static const unlockPendingTitle = '待解锁列表';
|
||||||
|
static const collectiblesPendingTitle = '待获取列表';
|
||||||
|
static const unlockPendingEmpty = '列表为空,可在上方填写歌曲 ID 并勾选难度后添加。';
|
||||||
|
static const collectiblesPendingEmpty = '列表为空,可在上方选择类型与 ID 后添加。';
|
||||||
|
static const unlockNeedList = '请至少添加一首要解锁的歌曲。';
|
||||||
|
static const collectiblesNeedList = '请至少添加一项收藏品。';
|
||||||
|
static const autoLogoutAndExit = '完成后自动退出登录并返回标题页';
|
||||||
|
static const exitingToTitle = '正在退出登录并返回标题页...';
|
||||||
static String unlockCooldownNotice(int remaining) =>
|
static String unlockCooldownNotice(int remaining) =>
|
||||||
'登录后需冷却 $ticketCooldownSeconds 秒,剩余 $remaining 秒后可执行解锁操作。';
|
'登录后需冷却 $ticketCooldownSeconds 秒,剩余 $remaining 秒后可执行解锁操作。';
|
||||||
static const unlockFetchData = '拉取数据';
|
static const unlockFetchData = '拉取数据';
|
||||||
@@ -181,7 +194,6 @@ class AppStrings {
|
|||||||
static const unlockRefetch = '重新拉取';
|
static const unlockRefetch = '重新拉取';
|
||||||
static const unlockNoData = '⚠️ 未拉取到用户数据,请先点「拉取数据」按钮获取。';
|
static const unlockNoData = '⚠️ 未拉取到用户数据,请先点「拉取数据」按钮获取。';
|
||||||
static const unlockFetched = '数据已拉取';
|
static const unlockFetched = '数据已拉取';
|
||||||
static const unlockAutoLogout = '完成后自动退出登录';
|
|
||||||
static const unlockMusicRun = '执行解锁';
|
static const unlockMusicRun = '执行解锁';
|
||||||
static const unlockRunning = '执行中...';
|
static const unlockRunning = '执行中...';
|
||||||
static const unlockStepFetch = '拉取用户数据';
|
static const unlockStepFetch = '拉取用户数据';
|
||||||
@@ -225,6 +237,39 @@ class AppStrings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TravelPartner 旅行伙伴 (ItemKind.Character = 9,走 userCharacterList)
|
||||||
|
static const travelPartnerFeatureTitle = '旅行伙伴';
|
||||||
|
static const travelPartnerFeatureDesc =
|
||||||
|
'发放旅行伙伴并编组出战槽位。角色走 userCharacterList(不是搭档 Partner=10,也不走 userItemList)。';
|
||||||
|
static const travelPartnerNotLoggedIn = '尚未登录游戏服务器,无法操作旅行伙伴。';
|
||||||
|
static String travelPartnerCooldownNotice(int remaining) =>
|
||||||
|
'登录后需冷却 $ticketCooldownSeconds 秒,剩余 $remaining 秒后可操作旅行伙伴。';
|
||||||
|
static const travelPartnerFetchData = '拉取角色数据';
|
||||||
|
static const travelPartnerFetching = '拉取中...';
|
||||||
|
static const travelPartnerRefetch = '重新拉取';
|
||||||
|
static const travelPartnerFetched = '角色数据已拉取';
|
||||||
|
static const travelPartnerNoData = '⚠️ 尚未拉取,请先获取角色与用户数据。';
|
||||||
|
static const travelPartnerOwnedTitle = '已拥有旅行伙伴';
|
||||||
|
static const travelPartnerOwnedEmpty = '还没有任何旅行伙伴。';
|
||||||
|
static const travelPartnerGrantTitle = '发放新的旅行伙伴';
|
||||||
|
static const travelPartnerGrantEmpty = '未添加待发放角色。';
|
||||||
|
static const travelPartnerCharacterIdLabel = '旅行伙伴 ID';
|
||||||
|
static const travelPartnerCharacterIdHint = '例如 1001';
|
||||||
|
static const travelPartnerNeedCharacterId = '请填写旅行伙伴 ID。';
|
||||||
|
static const travelPartnerAlreadyOwned = '该旅行伙伴已拥有,无需重复发放。';
|
||||||
|
static const travelPartnerSlotTitle = '编组出战槽位';
|
||||||
|
static const travelPartnerSlotNone = '空';
|
||||||
|
static String travelPartnerSlotLabel(int index) =>
|
||||||
|
index == 0 ? '槽 0(队长)' : '槽 $index';
|
||||||
|
static const travelPartnerCopySlot0 = '把槽 0 复制到其他槽位';
|
||||||
|
static const travelPartnerCopyNeedSlot0 = '槽 0 还没有选择旅行伙伴。';
|
||||||
|
static const travelPartnerNotOwnedHint = '编组失败:你并不拥有该旅行伙伴。';
|
||||||
|
static const travelPartnerRun = '执行旅行伙伴操作';
|
||||||
|
static const travelPartnerRunning = '执行中...';
|
||||||
|
static const travelPartnerSuccess = '旅行伙伴数据上传完成';
|
||||||
|
static const travelPartnerStepFetch = '拉取角色与用户数据';
|
||||||
|
static const travelPartnerStepUpload = '上传旅行伙伴数据';
|
||||||
|
|
||||||
// HighRiskFeature hub (中转页)
|
// HighRiskFeature hub (中转页)
|
||||||
static const musicRiskHubTitle = '高危功能';
|
static const musicRiskHubTitle = '高危功能';
|
||||||
static const musicRiskHubDesc = '以下功能会向服务器发送伪造数据,属于高风险操作,请谨慎使用。';
|
static const musicRiskHubDesc = '以下功能会向服务器发送伪造数据,属于高风险操作,请谨慎使用。';
|
||||||
|
|||||||
+2
-2
@@ -158,8 +158,8 @@ class _LoginPageState extends State<LoginPage> {
|
|||||||
body: Center(
|
body: Center(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 48),
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 48),
|
||||||
child: ConstrainedBox(
|
child: responsiveBody(
|
||||||
constraints: BoxConstraints(maxWidth: responsiveMaxWidth(context)),
|
context,
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
/// GetUserRatingApi 之外的角色(旅行伙伴)数据。
|
||||||
|
///
|
||||||
|
/// 对应客户端 `Net.VO.Mai2.UserCharacter`:只有 4 个字段,走
|
||||||
|
/// `UpsertUserAllApi` 的 `upsertUserAll.userCharacterList`,主键是
|
||||||
|
/// `characterId`(见 VOExtensions.cs:265 的 BuildListData 比较器)。
|
||||||
|
///
|
||||||
|
/// 注意:旅行伙伴不是「搭档」。搭档是 ItemKind.Partner = 10,走 userItemList;
|
||||||
|
/// 角色是 ItemKind.Character = 9,客户端的 ExportUserItems() 并不导出它,
|
||||||
|
/// 角色的拥有状态由 userCharacterList 表达。
|
||||||
|
class UserCharacterBean {
|
||||||
|
final int characterId;
|
||||||
|
final int level;
|
||||||
|
final int awakening;
|
||||||
|
final int useCount;
|
||||||
|
|
||||||
|
const UserCharacterBean({
|
||||||
|
required this.characterId,
|
||||||
|
required this.level,
|
||||||
|
required this.awakening,
|
||||||
|
required this.useCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// wire 上的 UserCharacter,字段与顺序与客户端声明一致。
|
||||||
|
Map<String, dynamic> toWireJson() => {
|
||||||
|
'characterId': characterId,
|
||||||
|
'level': level,
|
||||||
|
'awakening': awakening,
|
||||||
|
'useCount': useCount,
|
||||||
|
};
|
||||||
|
|
||||||
|
UserCharacterBean copyWith({int? level, int? awakening, int? useCount}) =>
|
||||||
|
UserCharacterBean(
|
||||||
|
characterId: characterId,
|
||||||
|
level: level ?? this.level,
|
||||||
|
awakening: awakening ?? this.awakening,
|
||||||
|
useCount: useCount ?? this.useCount,
|
||||||
|
);
|
||||||
|
|
||||||
|
static int _toInt(dynamic v) {
|
||||||
|
if (v == null) return 0;
|
||||||
|
if (v is int) return v;
|
||||||
|
if (v is num) return v.toInt();
|
||||||
|
if (v is String) return int.tryParse(v) ?? 0;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
factory UserCharacterBean.fromJson(Map<String, dynamic> json) =>
|
||||||
|
UserCharacterBean(
|
||||||
|
characterId: _toInt(json['characterId']),
|
||||||
|
level: _toInt(json['level']),
|
||||||
|
awakening: _toInt(json['awakening']),
|
||||||
|
useCount: _toInt(json['useCount']),
|
||||||
|
);
|
||||||
|
|
||||||
|
static List<UserCharacterBean> listFromResponse(Map<String, dynamic> json) {
|
||||||
|
final raw = json['userCharacterList'];
|
||||||
|
if (raw is! List) return const [];
|
||||||
|
return raw
|
||||||
|
.whereType<Map<String, dynamic>>()
|
||||||
|
.map(UserCharacterBean.fromJson)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `UserDetail.charaSlot` 固定 5 个槽,槽 0 是队长
|
||||||
|
/// (客户端 CharactorSlotController.cs:27 对 i==0 用 leaderCharaSlotPrefab)。
|
||||||
|
List<int> normalizeCharaSlot(List<int> slots) =>
|
||||||
|
List<int>.generate(5, (i) => i < slots.length ? slots[i] : 0);
|
||||||
@@ -12,8 +12,8 @@ class AboutPage extends StatelessWidget {
|
|||||||
|
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
child: ConstrainedBox(
|
child: responsiveBody(
|
||||||
constraints: BoxConstraints(maxWidth: responsiveMaxWidth(context)),
|
context,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
|
|||||||
+69
-44
@@ -3,6 +3,7 @@ import 'dart:ui' as ui;
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/rendering.dart';
|
import 'package:flutter/rendering.dart';
|
||||||
|
|
||||||
|
import '../config/responsive.dart';
|
||||||
import '../config/strings.dart';
|
import '../config/strings.dart';
|
||||||
import '../config/title_server_config.dart';
|
import '../config/title_server_config.dart';
|
||||||
import '../models/user_rating.dart';
|
import '../models/user_rating.dart';
|
||||||
@@ -189,56 +190,80 @@ class _Best50PageState extends State<Best50Page> {
|
|||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
if (!_musicReady)
|
responsiveBody(
|
||||||
_NoticeBanner(theme: theme, text: AppStrings.best50NoMusicData),
|
context,
|
||||||
Padding(
|
child: Column(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
children: [
|
||||||
child: SizedBox(
|
if (!_musicReady)
|
||||||
width: double.infinity,
|
_NoticeBanner(
|
||||||
child: FilledButton.icon(
|
theme: theme,
|
||||||
onPressed: _exporting ? null : _export,
|
text: AppStrings.best50NoMusicData,
|
||||||
icon: _exporting
|
),
|
||||||
? const SizedBox(
|
Padding(
|
||||||
width: 20,
|
padding: const EdgeInsets.symmetric(
|
||||||
height: 20,
|
horizontal: 16,
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
vertical: 8,
|
||||||
)
|
),
|
||||||
: const Icon(Icons.download, size: 20),
|
child: SizedBox(
|
||||||
label: Text(
|
width: double.infinity,
|
||||||
_exporting
|
child: FilledButton.icon(
|
||||||
? AppStrings.best50Exporting
|
onPressed: _exporting ? null : _export,
|
||||||
: AppStrings.best50Export,
|
icon: _exporting
|
||||||
),
|
? const SizedBox(
|
||||||
style: FilledButton.styleFrom(
|
width: 20,
|
||||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
height: 20,
|
||||||
shape: RoundedRectangleBorder(
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
borderRadius: BorderRadius.circular(12),
|
)
|
||||||
|
: const Icon(Icons.download, size: 20),
|
||||||
|
label: Text(
|
||||||
|
_exporting
|
||||||
|
? AppStrings.best50Exporting
|
||||||
|
: AppStrings.best50Export,
|
||||||
|
),
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: InteractiveViewer(
|
child: LayoutBuilder(
|
||||||
maxScale: 6,
|
builder: (context, constraints) {
|
||||||
boundaryMargin: const EdgeInsets.all(24),
|
// 按视口宽度适配:横屏/桌面下海报占满宽度、竖向拖动看全图,
|
||||||
// FittedBox 会按视口把固定尺寸的海报缩出来;它给子节点无限约束,
|
// 而不是被 contain 缩成中间一小块、两侧大片空白。
|
||||||
// 所以海报仍保持 1762x1850,RepaintBoundary 录到的也是原始尺寸
|
// minScale 再允许缩到整张可见,导出仍是原始 1762x1850。
|
||||||
// (祖先变换不会被烘进边界里),导出不会糊。
|
final fitWidth =
|
||||||
child: FittedBox(
|
constraints.maxWidth / Best50Poster.canvasWidth;
|
||||||
fit: BoxFit.contain,
|
final scaledHeight = Best50Poster.canvasHeight * fitWidth;
|
||||||
alignment: Alignment.topCenter,
|
final containScale = constraints.maxHeight / scaledHeight;
|
||||||
child: RepaintBoundary(
|
final minScale = containScale < 1 ? containScale : 1.0;
|
||||||
key: _posterKey,
|
|
||||||
child: Best50Poster(
|
return InteractiveViewer(
|
||||||
userName: widget.userName,
|
constrained: false,
|
||||||
iconId: widget.iconId,
|
minScale: minScale,
|
||||||
playerRating: widget.playerRating,
|
maxScale: minScale * 6,
|
||||||
best35: best35,
|
boundaryMargin: const EdgeInsets.all(24),
|
||||||
best15: best15,
|
child: FittedBox(
|
||||||
|
fit: BoxFit.fitWidth,
|
||||||
|
child: RepaintBoundary(
|
||||||
|
key: _posterKey,
|
||||||
|
child: Best50Poster(
|
||||||
|
userName: widget.userName,
|
||||||
|
iconId: widget.iconId,
|
||||||
|
playerRating: widget.playerRating,
|
||||||
|
best35: best35,
|
||||||
|
best15: best15,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
),
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ class CollectiblesPage extends StatelessWidget {
|
|||||||
final int? loginDateTime;
|
final int? loginDateTime;
|
||||||
final int? loginId;
|
final int? loginId;
|
||||||
final String? lastLoginDate;
|
final String? lastLoginDate;
|
||||||
final Future<void> Function()? onLogoutRequested;
|
final Future<void> Function()? onExitToTitle;
|
||||||
|
|
||||||
const CollectiblesPage({
|
const CollectiblesPage({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -22,7 +22,7 @@ class CollectiblesPage extends StatelessWidget {
|
|||||||
this.loginDateTime,
|
this.loginDateTime,
|
||||||
this.loginId,
|
this.loginId,
|
||||||
this.lastLoginDate,
|
this.lastLoginDate,
|
||||||
this.onLogoutRequested,
|
this.onExitToTitle,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -37,7 +37,7 @@ class CollectiblesPage extends StatelessWidget {
|
|||||||
loginDateTime: loginDateTime,
|
loginDateTime: loginDateTime,
|
||||||
loginId: loginId,
|
loginId: loginId,
|
||||||
lastLoginDate: lastLoginDate,
|
lastLoginDate: lastLoginDate,
|
||||||
onLogoutRequested: onLogoutRequested,
|
onExitToTitle: onExitToTitle,
|
||||||
mode: MusicRiskFeatureMode.collectibles,
|
mode: MusicRiskFeatureMode.collectibles,
|
||||||
featureTitle: AppStrings.collectiblesFeatureTitle,
|
featureTitle: AppStrings.collectiblesFeatureTitle,
|
||||||
featureDesc: AppStrings.collectiblesFeatureDesc,
|
featureDesc: AppStrings.collectiblesFeatureDesc,
|
||||||
|
|||||||
@@ -74,8 +74,8 @@ class ConfigExportImportPage extends StatelessWidget {
|
|||||||
body: Center(
|
body: Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
child: ConstrainedBox(
|
child: responsiveBody(
|
||||||
constraints: BoxConstraints(maxWidth: responsiveMaxWidth(context)),
|
context,
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ 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 'travel_partner_page.dart';
|
||||||
import 'unlock_music_page.dart';
|
import 'unlock_music_page.dart';
|
||||||
|
|
||||||
/// 高危功能中转页:导航到 UnlockMusic (解锁歌曲) / 收藏品获取。
|
/// 高危功能中转页:导航到 UnlockMusic (解锁歌曲) / 收藏品获取。
|
||||||
@@ -22,8 +23,8 @@ class HighRiskFeaturePage extends StatefulWidget {
|
|||||||
final int? loginId;
|
final int? loginId;
|
||||||
final String? lastLoginDate;
|
final String? lastLoginDate;
|
||||||
|
|
||||||
/// 由 HomePage 负责发送 logout 包。
|
/// 完成后自动退出登录并返回标题页,由 HomePage 执行结算+退登+pop。
|
||||||
final Future<void> Function()? onLogoutRequested;
|
final Future<void> Function()? onExitToTitle;
|
||||||
|
|
||||||
const HighRiskFeaturePage({
|
const HighRiskFeaturePage({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -32,7 +33,7 @@ class HighRiskFeaturePage extends StatefulWidget {
|
|||||||
this.loginDateTime,
|
this.loginDateTime,
|
||||||
this.loginId,
|
this.loginId,
|
||||||
this.lastLoginDate,
|
this.lastLoginDate,
|
||||||
this.onLogoutRequested,
|
this.onExitToTitle,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -99,7 +100,7 @@ class _HighRiskFeaturePageState extends State<HighRiskFeaturePage> {
|
|||||||
loginDateTime: widget.loginDateTime,
|
loginDateTime: widget.loginDateTime,
|
||||||
loginId: widget.loginId,
|
loginId: widget.loginId,
|
||||||
lastLoginDate: widget.lastLoginDate,
|
lastLoginDate: widget.lastLoginDate,
|
||||||
onLogoutRequested: widget.onLogoutRequested,
|
onExitToTitle: widget.onExitToTitle,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -114,7 +115,21 @@ class _HighRiskFeaturePageState extends State<HighRiskFeaturePage> {
|
|||||||
loginDateTime: widget.loginDateTime,
|
loginDateTime: widget.loginDateTime,
|
||||||
loginId: widget.loginId,
|
loginId: widget.loginId,
|
||||||
lastLoginDate: widget.lastLoginDate,
|
lastLoginDate: widget.lastLoginDate,
|
||||||
onLogoutRequested: widget.onLogoutRequested,
|
onExitToTitle: widget.onExitToTitle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _openTravelPartner() {
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => TravelPartnerPage(
|
||||||
|
userId: widget.userId,
|
||||||
|
cookies: widget.cookies,
|
||||||
|
loginDateTime: widget.loginDateTime,
|
||||||
|
loginId: widget.loginId,
|
||||||
|
onExitToTitle: widget.onExitToTitle,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -144,8 +159,8 @@ class _HighRiskFeaturePageState extends State<HighRiskFeaturePage> {
|
|||||||
|
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: ConstrainedBox(
|
child: responsiveBody(
|
||||||
constraints: BoxConstraints(maxWidth: responsiveMaxWidth(context)),
|
context,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
if (widget.loginDateTime == null) _buildNotLoggedInBanner(theme),
|
if (widget.loginDateTime == null) _buildNotLoggedInBanner(theme),
|
||||||
@@ -168,6 +183,14 @@ class _HighRiskFeaturePageState extends State<HighRiskFeaturePage> {
|
|||||||
desc: AppStrings.collectiblesFeatureDesc,
|
desc: AppStrings.collectiblesFeatureDesc,
|
||||||
onTap: _openCollectibles,
|
onTap: _openCollectibles,
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_buildNavCard(
|
||||||
|
theme,
|
||||||
|
icon: Icons.workspace_premium_outlined,
|
||||||
|
title: AppStrings.travelPartnerFeatureTitle,
|
||||||
|
desc: AppStrings.travelPartnerFeatureDesc,
|
||||||
|
onTap: _openTravelPartner,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -177,7 +177,10 @@ class _HomePageState extends State<HomePage> {
|
|||||||
}
|
}
|
||||||
_session.reset();
|
_session.reset();
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
Navigator.of(context).pop();
|
// 解锁/收藏品/旅行伙伴这些页是 push 在 HomePage 之上的,只 pop() 一层会
|
||||||
|
// 退回「风险」hub,而此时 _loggingOut 仍为 true,PopScope(canPop:false) +
|
||||||
|
// 返回键转圈 = 卡死。必须一路 pop 回根路由(主标题)。
|
||||||
|
Navigator.of(context).popUntil((route) => route.isFirst);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,8 +236,8 @@ class _HomePageState extends State<HomePage> {
|
|||||||
loginDateTime: _session.gameLogin?.loginDateTime,
|
loginDateTime: _session.gameLogin?.loginDateTime,
|
||||||
loginId: _session.gameLogin?.loginId,
|
loginId: _session.gameLogin?.loginId,
|
||||||
lastLoginDate: _session.gameLogin?.lastLoginDate,
|
lastLoginDate: _session.gameLogin?.lastLoginDate,
|
||||||
onLogoutRequested:
|
onExitToTitle:
|
||||||
_session.gameLogin != null ? _logoutSession : null,
|
_session.gameLogin != null ? _performLogoutAndExit : null,
|
||||||
),
|
),
|
||||||
SettingsPage(showAppBar: false),
|
SettingsPage(showAppBar: false),
|
||||||
const AboutPage(),
|
const AboutPage(),
|
||||||
@@ -357,8 +360,8 @@ class _HomePageState extends State<HomePage> {
|
|||||||
}) {
|
}) {
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: ConstrainedBox(
|
child: responsiveBody(
|
||||||
constraints: BoxConstraints(maxWidth: responsiveMaxWidth(context)),
|
context,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
if (preview.errorId != 0) _ErrorIdBanner(theme: theme, errorId: preview.errorId),
|
if (preview.errorId != 0) _ErrorIdBanner(theme: theme, errorId: preview.errorId),
|
||||||
@@ -429,8 +432,8 @@ class _HomePageState extends State<HomePage> {
|
|||||||
) {
|
) {
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: ConstrainedBox(
|
child: responsiveBody(
|
||||||
constraints: BoxConstraints(maxWidth: responsiveMaxWidth(context)),
|
context,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
_ProfileFromUserDataCard(theme: theme, data: data),
|
_ProfileFromUserDataCard(theme: theme, data: data),
|
||||||
|
|||||||
@@ -25,6 +25,39 @@ enum MusicRiskFeatureMode {
|
|||||||
collectibles,
|
collectibles,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 一行待提交的收藏品道具(对应 wire 上的 UserItem)。
|
||||||
|
class _PendingItem {
|
||||||
|
final int kind;
|
||||||
|
final int id;
|
||||||
|
|
||||||
|
const _PendingItem(this.kind, this.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一行待提交的歌曲解锁,展开成 itemKind 5/6/7 的若干行。
|
||||||
|
class _PendingMusic {
|
||||||
|
final int musicId;
|
||||||
|
final bool base;
|
||||||
|
final bool master;
|
||||||
|
final bool remaster;
|
||||||
|
|
||||||
|
const _PendingMusic(this.musicId, this.base, this.master, this.remaster);
|
||||||
|
|
||||||
|
bool get hasAnyOption => base || master || remaster;
|
||||||
|
|
||||||
|
/// 难度由 itemKind 表达,不写在 musicDetail 上。
|
||||||
|
List<int> get itemKinds => [
|
||||||
|
if (base) 5,
|
||||||
|
if (master) 6,
|
||||||
|
if (remaster) 7,
|
||||||
|
];
|
||||||
|
|
||||||
|
String get optionLabel => [
|
||||||
|
if (base) '歌曲',
|
||||||
|
if (master) 'Master',
|
||||||
|
if (remaster) 'Re:Master',
|
||||||
|
].join('/');
|
||||||
|
}
|
||||||
|
|
||||||
class MusicRiskFeatureView extends StatefulWidget {
|
class MusicRiskFeatureView extends StatefulWidget {
|
||||||
final int userId;
|
final int userId;
|
||||||
final String? cookies;
|
final String? cookies;
|
||||||
@@ -37,8 +70,9 @@ class MusicRiskFeatureView extends StatefulWidget {
|
|||||||
final int? loginId;
|
final int? loginId;
|
||||||
final String? lastLoginDate;
|
final String? lastLoginDate;
|
||||||
|
|
||||||
/// 当用户勾选"完成后自动退出登录"时,由 HomePage 负责发送 logout 包。
|
/// 完成后自动退出登录并返回标题页时调用;由 HomePage 提供,
|
||||||
final Future<void> Function()? onLogoutRequested;
|
/// 内部会等待结算、发 UserLogoutApi、重置会话并 pop 回主标题。
|
||||||
|
final Future<void> Function()? onExitToTitle;
|
||||||
|
|
||||||
final MusicRiskFeatureMode mode;
|
final MusicRiskFeatureMode mode;
|
||||||
final String featureTitle;
|
final String featureTitle;
|
||||||
@@ -51,7 +85,7 @@ class MusicRiskFeatureView extends StatefulWidget {
|
|||||||
this.loginDateTime,
|
this.loginDateTime,
|
||||||
this.loginId,
|
this.loginId,
|
||||||
this.lastLoginDate,
|
this.lastLoginDate,
|
||||||
this.onLogoutRequested,
|
this.onExitToTitle,
|
||||||
this.mode = MusicRiskFeatureMode.unlockMusic,
|
this.mode = MusicRiskFeatureMode.unlockMusic,
|
||||||
required this.featureTitle,
|
required this.featureTitle,
|
||||||
required this.featureDesc,
|
required this.featureDesc,
|
||||||
@@ -75,12 +109,19 @@ class _MusicRiskFeatureViewState extends State<MusicRiskFeatureView> {
|
|||||||
final _itemIdController = TextEditingController();
|
final _itemIdController = TextEditingController();
|
||||||
int _itemKind = 3;
|
int _itemKind = 3;
|
||||||
|
|
||||||
|
// ── 待提交列表:userItemList 可以一次带多行 ──
|
||||||
|
// 真客户端的 ExportUserItems() 就是把 Plate/Title/Icon/Partner/Frame/Ticket 与
|
||||||
|
// MusicUnlock/Master/Remaster 各列表拼成多行 UserItem,BuildListData 再只发增量、
|
||||||
|
// isNewItemList 一行一个字符。所以多行同类/异类混发是客户端自己的做法。
|
||||||
|
final List<_PendingItem> _pendingItems = [];
|
||||||
|
final List<_PendingMusic> _pendingMusics = [];
|
||||||
|
|
||||||
// ── 运行状态 ──
|
// ── 运行状态 ──
|
||||||
Map<String, Map<String, dynamic>>? _userAllData;
|
Map<String, Map<String, dynamic>>? _userAllData;
|
||||||
bool _fetching = false;
|
bool _fetching = false;
|
||||||
String? _fetchError;
|
String? _fetchError;
|
||||||
bool _running = false;
|
bool _running = false;
|
||||||
bool _autoLogout = false;
|
bool _autoLogout = true;
|
||||||
MusicRiskStep _step = MusicRiskStep.idle;
|
MusicRiskStep _step = MusicRiskStep.idle;
|
||||||
String _stepMessage = '';
|
String _stepMessage = '';
|
||||||
String? _error;
|
String? _error;
|
||||||
@@ -153,10 +194,63 @@ class _MusicRiskFeatureViewState extends State<MusicRiskFeatureView> {
|
|||||||
|
|
||||||
int _itemId() => int.tryParse(_itemIdController.text.trim()) ?? -1;
|
int _itemId() => int.tryParse(_itemIdController.text.trim()) ?? -1;
|
||||||
|
|
||||||
|
void _snack(String message) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(message)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 把当前输入框里的收藏品加入待提交列表。
|
||||||
|
void _addItemToList() {
|
||||||
|
final id = _itemId();
|
||||||
|
if (id <= 0) {
|
||||||
|
_snack(AppStrings.collectiblesNeedItemId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_pendingItems.any((e) => e.kind == _itemKind && e.id == id)) {
|
||||||
|
_snack(AppStrings.listDuplicate);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_pendingItems.add(_PendingItem(_itemKind, id));
|
||||||
|
_itemIdController.clear();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 把当前输入框里的歌曲+勾选的难度加入待解锁列表。
|
||||||
|
void _addMusicToList() {
|
||||||
|
final id = _unlockMusicId();
|
||||||
|
if (id <= 0) {
|
||||||
|
_snack(AppStrings.unlockNeedMusicId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!_unlockMusic && !_unlockMaster && !_unlockRemaster) {
|
||||||
|
_snack(AppStrings.unlockNeedOption);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_pendingMusics.any((e) => e.musicId == id)) {
|
||||||
|
_snack(AppStrings.listDuplicate);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_pendingMusics.add(
|
||||||
|
_PendingMusic(id, _unlockMusic, _unlockMaster, _unlockRemaster),
|
||||||
|
);
|
||||||
|
_unlockMusicIdController.clear();
|
||||||
|
_unlockMusic = _unlockMaster = _unlockRemaster = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// eaquira UnlockMusic.py 使用 settings 中的默认 musicData。
|
/// eaquira UnlockMusic.py 使用 settings 中的默认 musicData。
|
||||||
|
/// 解锁状态本身由 userItemList 的 itemKind 5/6/7 行表达,这里只保留一条
|
||||||
|
/// 记录以维持 payload 形状,取列表首条歌曲 ID。
|
||||||
Map<String, dynamic> _buildMusicData() {
|
Map<String, dynamic> _buildMusicData() {
|
||||||
return {
|
return {
|
||||||
'musicId': _isUnlock ? _unlockMusicId() : _defaultMusicId,
|
'musicId': _isUnlock
|
||||||
|
? (_pendingMusics.isNotEmpty
|
||||||
|
? _pendingMusics.first.musicId
|
||||||
|
: _defaultMusicId)
|
||||||
|
: _defaultMusicId,
|
||||||
'level': 0,
|
'level': 0,
|
||||||
'playCount': 1,
|
'playCount': 1,
|
||||||
'achievement': 0,
|
'achievement': 0,
|
||||||
@@ -168,16 +262,38 @@ class _MusicRiskFeatureViewState extends State<MusicRiskFeatureView> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 汇总成 wire 上的 userItemList 行。
|
||||||
|
List<Map<String, dynamic>> _buildUserItemList() {
|
||||||
|
if (_isUnlock) {
|
||||||
|
return [
|
||||||
|
for (final music in _pendingMusics)
|
||||||
|
for (final kind in music.itemKinds)
|
||||||
|
{
|
||||||
|
'itemKind': kind,
|
||||||
|
'itemId': music.musicId,
|
||||||
|
'stock': 1,
|
||||||
|
'isValid': true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
for (final item in _pendingItems)
|
||||||
|
{
|
||||||
|
'itemKind': item.kind,
|
||||||
|
'itemId': item.id,
|
||||||
|
'stock': 1,
|
||||||
|
'isValid': true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
String? _validateInputs() {
|
String? _validateInputs() {
|
||||||
if (_isUnlock) {
|
if (_isUnlock) {
|
||||||
if (_unlockMusicId() <= 0) return AppStrings.unlockNeedMusicId;
|
if (_pendingMusics.isEmpty) return AppStrings.unlockNeedList;
|
||||||
if (!_unlockMusic && !_unlockMaster && !_unlockRemaster) {
|
|
||||||
return AppStrings.unlockNeedOption;
|
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_itemId() <= 0) return AppStrings.collectiblesNeedItemId;
|
if (_pendingItems.isEmpty) return AppStrings.collectiblesNeedList;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,35 +380,27 @@ class _MusicRiskFeatureViewState extends State<MusicRiskFeatureView> {
|
|||||||
generalUserInfo: data,
|
generalUserInfo: data,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
builder.applyItemListPatch(packet, items: _buildUserItemList());
|
||||||
if (_isUnlock) {
|
if (_isUnlock) {
|
||||||
builder.applyMusicUnlockPatch(
|
builder.applyMusicDetailPatch(packet, musicData: musicData);
|
||||||
packet,
|
|
||||||
musicData: musicData,
|
|
||||||
musicId: _unlockMusicId(),
|
|
||||||
unlockMusic: _unlockMusic,
|
|
||||||
unlockMaster: _unlockMaster,
|
|
||||||
unlockRemaster: _unlockRemaster,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
builder.applyItemListPatch(packet, items: [
|
|
||||||
{'itemKind': _itemKind, 'itemId': _itemId(), 'stock': 1, 'isValid': true},
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await service.upsertUserAll(packet, widget.userId);
|
await service.upsertUserAll(packet, widget.userId);
|
||||||
|
|
||||||
if (_autoLogout && widget.onLogoutRequested != null) {
|
|
||||||
_updateStep(MusicRiskStep.logout);
|
|
||||||
await Future.delayed(const Duration(seconds: 5));
|
|
||||||
await widget.onLogoutRequested!();
|
|
||||||
}
|
|
||||||
|
|
||||||
_updateStep(
|
_updateStep(
|
||||||
MusicRiskStep.complete,
|
MusicRiskStep.complete,
|
||||||
_isUnlock
|
_isUnlock
|
||||||
? AppStrings.unlockMusicSuccess
|
? AppStrings.unlockMusicSuccess
|
||||||
: AppStrings.collectiblesSuccess,
|
: AppStrings.collectiblesSuccess,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (_autoLogout && widget.onExitToTitle != null) {
|
||||||
|
// 让成功状态先显示一下,再交给 HomePage 结算、退登并 pop 回主标题。
|
||||||
|
await Future.delayed(const Duration(seconds: 2));
|
||||||
|
if (!mounted) return;
|
||||||
|
_updateStep(MusicRiskStep.logout, AppStrings.exitingToTitle);
|
||||||
|
await widget.onExitToTitle!();
|
||||||
|
}
|
||||||
} on TitleApiException catch (e) {
|
} on TitleApiException catch (e) {
|
||||||
_updateStep(MusicRiskStep.failed, e.message);
|
_updateStep(MusicRiskStep.failed, e.message);
|
||||||
setState(() => _error = e.message);
|
setState(() => _error = e.message);
|
||||||
@@ -336,8 +444,8 @@ class _MusicRiskFeatureViewState extends State<MusicRiskFeatureView> {
|
|||||||
|
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: ConstrainedBox(
|
child: responsiveBody(
|
||||||
constraints: BoxConstraints(maxWidth: responsiveMaxWidth(context)),
|
context,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
if (widget.loginDateTime == null) _buildNotLoggedInBanner(theme),
|
if (widget.loginDateTime == null) _buildNotLoggedInBanner(theme),
|
||||||
@@ -512,12 +620,107 @@ class _MusicRiskFeatureViewState extends State<MusicRiskFeatureView> {
|
|||||||
enabled: enabled,
|
enabled: enabled,
|
||||||
onChanged: (v) => setState(() => _unlockRemaster = v),
|
onChanged: (v) => setState(() => _unlockRemaster = v),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_buildAddButton(theme, onPressed: enabled ? _addMusicToList : null),
|
||||||
|
_buildPendingList(
|
||||||
|
theme,
|
||||||
|
title: AppStrings.unlockPendingTitle,
|
||||||
|
emptyHint: AppStrings.unlockPendingEmpty,
|
||||||
|
count: _pendingMusics.length,
|
||||||
|
rows: [
|
||||||
|
for (var i = 0; i < _pendingMusics.length; i++)
|
||||||
|
_buildPendingRow(
|
||||||
|
theme,
|
||||||
|
leading: '#${_pendingMusics[i].musicId}',
|
||||||
|
trailing: _pendingMusics[i].optionLabel,
|
||||||
|
onRemove: () => setState(() => _pendingMusics.removeAt(i)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildAddButton(ThemeData theme, {VoidCallback? onPressed}) {
|
||||||
|
return SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: onPressed,
|
||||||
|
icon: const Icon(Icons.add, size: 18),
|
||||||
|
label: const Text(AppStrings.listAddButton),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPendingList(
|
||||||
|
ThemeData theme, {
|
||||||
|
required String title,
|
||||||
|
required String emptyHint,
|
||||||
|
required int count,
|
||||||
|
required List<Widget> rows,
|
||||||
|
}) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
Text(
|
||||||
|
'$title ($count)',
|
||||||
|
style: theme.textTheme.labelLarge?.copyWith(
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
if (count == 0)
|
||||||
|
Text(
|
||||||
|
emptyHint,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
...rows,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPendingRow(
|
||||||
|
ThemeData theme, {
|
||||||
|
required String leading,
|
||||||
|
required String trailing,
|
||||||
|
required VoidCallback onRemove,
|
||||||
|
}) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'$leading $trailing',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.close, size: 18),
|
||||||
|
tooltip: AppStrings.listRemoveTooltip,
|
||||||
|
onPressed: _running ? null : onRemove,
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildUnlockCheckbox(
|
Widget _buildUnlockCheckbox(
|
||||||
ThemeData theme, {
|
ThemeData theme, {
|
||||||
required bool value,
|
required bool value,
|
||||||
@@ -595,6 +798,24 @@ class _MusicRiskFeatureViewState extends State<MusicRiskFeatureView> {
|
|||||||
contentPadding: const EdgeInsets.all(14),
|
contentPadding: const EdgeInsets.all(14),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_buildAddButton(theme, onPressed: enabled ? _addItemToList : null),
|
||||||
|
_buildPendingList(
|
||||||
|
theme,
|
||||||
|
title: AppStrings.collectiblesPendingTitle,
|
||||||
|
emptyHint: AppStrings.collectiblesPendingEmpty,
|
||||||
|
count: _pendingItems.length,
|
||||||
|
rows: [
|
||||||
|
for (var i = 0; i < _pendingItems.length; i++)
|
||||||
|
_buildPendingRow(
|
||||||
|
theme,
|
||||||
|
leading:
|
||||||
|
'${AppStrings.collectiblesItemKindName(_pendingItems[i].kind)} (${_pendingItems[i].kind})',
|
||||||
|
trailing: '#${_pendingItems[i].id}',
|
||||||
|
onRemove: () => setState(() => _pendingItems.removeAt(i)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -693,7 +914,7 @@ class _MusicRiskFeatureViewState extends State<MusicRiskFeatureView> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildAutoLogoutToggle(ThemeData theme) {
|
Widget _buildAutoLogoutToggle(ThemeData theme) {
|
||||||
final enabled = widget.onLogoutRequested != null && !_running;
|
final enabled = widget.onExitToTitle != null && !_running;
|
||||||
return InkWell(
|
return InkWell(
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
onTap: enabled
|
onTap: enabled
|
||||||
@@ -712,7 +933,7 @@ class _MusicRiskFeatureViewState extends State<MusicRiskFeatureView> {
|
|||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
AppStrings.unlockAutoLogout,
|
AppStrings.autoLogoutAndExit,
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
color: enabled
|
color: enabled
|
||||||
? null
|
? null
|
||||||
|
|||||||
@@ -107,8 +107,8 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
: null,
|
: null,
|
||||||
body: SingleChildScrollView(
|
body: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(20),
|
padding: const EdgeInsets.all(20),
|
||||||
child: ConstrainedBox(
|
child: responsiveBody(
|
||||||
constraints: BoxConstraints(maxWidth: responsiveMaxWidth(context)),
|
context,
|
||||||
child: Form(
|
child: Form(
|
||||||
key: _formKey,
|
key: _formKey,
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|||||||
@@ -257,8 +257,8 @@ class _TicketPageState extends State<TicketPage> {
|
|||||||
|
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: ConstrainedBox(
|
child: responsiveBody(
|
||||||
constraints: BoxConstraints(maxWidth: responsiveMaxWidth(context)),
|
context,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
if (widget.loginDateTime == null) _buildNotLoggedInBanner(theme),
|
if (widget.loginDateTime == null) _buildNotLoggedInBanner(theme),
|
||||||
|
|||||||
@@ -0,0 +1,766 @@
|
|||||||
|
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_character.dart';
|
||||||
|
import '../services/title_api_service.dart';
|
||||||
|
import '../services/user_all_payload_builder.dart';
|
||||||
|
|
||||||
|
/// 旅行伙伴页:发放角色 + 编组出战槽位。
|
||||||
|
///
|
||||||
|
/// 依据反编译客户端:
|
||||||
|
/// - 旅行伙伴是 `ItemKind.Character = 9`,与「搭档」`Partner = 10` 是两种东西。
|
||||||
|
/// - 客户端的 `ExportUserItems()` 不导出 itemKind 9;角色的拥有状态走
|
||||||
|
/// `upsertUserAll.userCharacterList`(`{characterId, level, awakening, useCount}`,
|
||||||
|
/// 主键 characterId,见 VOExtensions.cs:265)。
|
||||||
|
/// - 出战编组是 `UserDetail.charaSlot`,`int[5]`,槽 0 为队长
|
||||||
|
/// (CharactorSlotController.cs:27)。
|
||||||
|
class TravelPartnerPage extends StatefulWidget {
|
||||||
|
final int userId;
|
||||||
|
final String? cookies;
|
||||||
|
final int? loginDateTime;
|
||||||
|
final int? loginId;
|
||||||
|
|
||||||
|
/// 完成后退出登录并返回标题页,由 HomePage 提供。
|
||||||
|
final Future<void> Function()? onExitToTitle;
|
||||||
|
|
||||||
|
const TravelPartnerPage({
|
||||||
|
super.key,
|
||||||
|
required this.userId,
|
||||||
|
this.cookies,
|
||||||
|
this.loginDateTime,
|
||||||
|
this.loginId,
|
||||||
|
this.onExitToTitle,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<TravelPartnerPage> createState() => _TravelPartnerPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
enum _Step { idle, fetchData, upload, logout, complete, failed }
|
||||||
|
|
||||||
|
class _TravelPartnerPageState extends State<TravelPartnerPage> {
|
||||||
|
/// 与解锁/收藏品一致:这些操作不写真实成绩,playlog 沿用同一条占位记录。
|
||||||
|
static const int _placeholderMusicId = 11538;
|
||||||
|
|
||||||
|
final _grantIdController = TextEditingController();
|
||||||
|
|
||||||
|
List<UserCharacterBean> _owned = const [];
|
||||||
|
Map<String, Map<String, dynamic>> _userAllData = const {};
|
||||||
|
final List<int> _pendingGrant = [];
|
||||||
|
List<int> _slots = normalizeCharaSlot(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 TravelPartnerPage oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (oldWidget.loginDateTime != widget.loginDateTime) {
|
||||||
|
_syncCooldown();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_cooldownTimer?.cancel();
|
||||||
|
_grantIdController.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)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isAvailable(int characterId) =>
|
||||||
|
characterId != 0 &&
|
||||||
|
(_owned.any((c) => c.characterId == characterId) ||
|
||||||
|
_pendingGrant.contains(characterId));
|
||||||
|
|
||||||
|
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.getUserCharacters(widget.userId),
|
||||||
|
service.fetchUserAllData(widget.userId),
|
||||||
|
]);
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
final owned = results[0] as List<UserCharacterBean>;
|
||||||
|
final all = results[1] as Map<String, Map<String, dynamic>>;
|
||||||
|
final userData =
|
||||||
|
(all['GetUserDataApi']?['userData'] as Map<String, dynamic>?) ??
|
||||||
|
const {};
|
||||||
|
final slots = (userData['charaSlot'] as List<dynamic>? ?? const [])
|
||||||
|
.map((e) => e is int ? e : int.tryParse('$e') ?? 0)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_owned = owned;
|
||||||
|
_userAllData = all;
|
||||||
|
_slots = normalizeCharaSlot(slots);
|
||||||
|
// 槽位里引用了但列表没有的角色,保留为可选,避免下拉框丢项。
|
||||||
|
_loading = false;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _loading = false);
|
||||||
|
_snack('${AppStrings.loadFailed}: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _addGrant() {
|
||||||
|
final id = int.tryParse(_grantIdController.text.trim()) ?? -1;
|
||||||
|
if (id <= 0) {
|
||||||
|
_snack(AppStrings.travelPartnerNeedCharacterId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_owned.any((c) => c.characterId == id)) {
|
||||||
|
_snack(AppStrings.travelPartnerAlreadyOwned);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_pendingGrant.contains(id)) {
|
||||||
|
_snack(AppStrings.listDuplicate);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_pendingGrant.add(id);
|
||||||
|
_grantIdController.clear();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一键把槽 0 的旅行伙伴复制到其他四个槽位。
|
||||||
|
void _copySlot0ToOthers() {
|
||||||
|
final leader = _slots.isNotEmpty ? _slots[0] : 0;
|
||||||
|
if (leader == 0) {
|
||||||
|
_snack(AppStrings.travelPartnerCopyNeedSlot0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!_isAvailable(leader)) {
|
||||||
|
_snack(AppStrings.travelPartnerNotOwnedHint);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_slots = normalizeCharaSlot([for (var i = 0; i < 5; i++) leader]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _validate() {
|
||||||
|
if (_pendingGrant.isEmpty && _userAllData.isEmpty) {
|
||||||
|
return AppStrings.travelPartnerNoData;
|
||||||
|
}
|
||||||
|
if (_userAllData.isEmpty) return AppStrings.travelPartnerNoData;
|
||||||
|
for (final slot in _slots) {
|
||||||
|
if (slot != 0 && !_isAvailable(slot)) {
|
||||||
|
return AppStrings.travelPartnerNotOwnedHint;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _run() async {
|
||||||
|
if (!TitleServerConfigHolder().isConfigured) {
|
||||||
|
_snack(AppStrings.ticketNotConfigured);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final loginDateTime = widget.loginDateTime;
|
||||||
|
final loginId = widget.loginId;
|
||||||
|
if (loginDateTime == null || loginId == null) {
|
||||||
|
_snack(AppStrings.travelPartnerNotLoggedIn);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_cooldownRemaining > 0) {
|
||||||
|
_snack(AppStrings.travelPartnerCooldownNotice(_cooldownRemaining));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final validationError = _validate();
|
||||||
|
if (validationError != null) {
|
||||||
|
_snack(validationError);
|
||||||
|
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) {
|
||||||
|
_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,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (_pendingGrant.isNotEmpty) {
|
||||||
|
// 新角色给最小可用值;已拥有的不重发,避免把 level/awakening 打回。
|
||||||
|
builder.applyCharacterListPatch(
|
||||||
|
packet,
|
||||||
|
characters: [
|
||||||
|
for (final id in _pendingGrant)
|
||||||
|
{'characterId': id, 'level': 1, 'awakening': 0, 'useCount': 0},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
builder.applyCharaSlotPatch(packet, charaSlot: _slots);
|
||||||
|
|
||||||
|
await service.upsertUserAll(packet, widget.userId);
|
||||||
|
|
||||||
|
_updateStep(_Step.complete, AppStrings.travelPartnerSuccess);
|
||||||
|
|
||||||
|
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 Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text(AppStrings.travelPartnerFeatureTitle),
|
||||||
|
),
|
||||||
|
body: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: responsiveBody(
|
||||||
|
context,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
if (!_loggedIn)
|
||||||
|
_notice(
|
||||||
|
theme,
|
||||||
|
AppStrings.travelPartnerNotLoggedIn,
|
||||||
|
error: true,
|
||||||
|
),
|
||||||
|
if (_loggedIn && _cooldownRemaining > 0)
|
||||||
|
_notice(
|
||||||
|
theme,
|
||||||
|
AppStrings.travelPartnerCooldownNotice(_cooldownRemaining),
|
||||||
|
),
|
||||||
|
_sectionCard(
|
||||||
|
theme,
|
||||||
|
icon: Icons.card_giftcard,
|
||||||
|
title: AppStrings.travelPartnerFeatureDesc,
|
||||||
|
children: const [],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_fetchCard(theme),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_grantCard(theme),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_slotCard(theme),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_ownedCard(theme),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_autoLogoutToggle(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 _sectionCard(
|
||||||
|
ThemeData theme, {
|
||||||
|
required IconData icon,
|
||||||
|
required String title,
|
||||||
|
required List<Widget> children,
|
||||||
|
}) {
|
||||||
|
return _card(
|
||||||
|
theme,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
...children,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 _fetchCard(ThemeData theme) {
|
||||||
|
final hasData = _userAllData.isNotEmpty && _owned.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.travelPartnerFetched
|
||||||
|
: AppStrings.travelPartnerNoData,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: hasData ? Colors.green : theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(
|
||||||
|
height: 32,
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: _loading || _running ? null : _fetchData,
|
||||||
|
icon: _loading
|
||||||
|
? const SizedBox(
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.refresh, size: 16),
|
||||||
|
label: Text(
|
||||||
|
_loading
|
||||||
|
? AppStrings.travelPartnerFetching
|
||||||
|
: _userAllData.isNotEmpty
|
||||||
|
? AppStrings.travelPartnerRefetch
|
||||||
|
: AppStrings.travelPartnerFetchData,
|
||||||
|
style: theme.textTheme.labelSmall,
|
||||||
|
),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _grantCard(ThemeData theme) {
|
||||||
|
final enabled = !_running;
|
||||||
|
return _sectionCard(
|
||||||
|
theme,
|
||||||
|
icon: Icons.add_circle_outline,
|
||||||
|
title: AppStrings.travelPartnerGrantTitle,
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
TextField(
|
||||||
|
controller: _grantIdController,
|
||||||
|
enabled: enabled,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: AppStrings.travelPartnerCharacterIdLabel,
|
||||||
|
hintText: AppStrings.travelPartnerCharacterIdHint,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
contentPadding: const EdgeInsets.all(14),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: enabled ? _addGrant : null,
|
||||||
|
icon: const Icon(Icons.add, size: 18),
|
||||||
|
label: const Text(AppStrings.listAddButton),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Text(
|
||||||
|
_pendingGrant.isEmpty
|
||||||
|
? AppStrings.travelPartnerGrantEmpty
|
||||||
|
: _pendingGrant.map((e) => '#$e').join(' '),
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
color: _pendingGrant.isEmpty
|
||||||
|
? theme.colorScheme.onSurfaceVariant
|
||||||
|
: theme.colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _slotCard(ThemeData theme) {
|
||||||
|
// 下拉选项 = 已拥有 ∪ 待发放 ∪ 当前槽位已有值。
|
||||||
|
final optionIds = <int>{
|
||||||
|
..._owned.map((c) => c.characterId),
|
||||||
|
..._pendingGrant,
|
||||||
|
..._slots.where((e) => e != 0),
|
||||||
|
}.toList()
|
||||||
|
..sort();
|
||||||
|
|
||||||
|
return _sectionCard(
|
||||||
|
theme,
|
||||||
|
icon: Icons.groups_outlined,
|
||||||
|
title: AppStrings.travelPartnerSlotTitle,
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
for (var i = 0; i < 5; i++)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: DropdownButtonFormField<int>(
|
||||||
|
initialValue: _slots[i],
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: AppStrings.travelPartnerSlotLabel(i),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 14,
|
||||||
|
vertical: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
items: [
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: 0,
|
||||||
|
child: Text(
|
||||||
|
AppStrings.travelPartnerSlotNone,
|
||||||
|
style: theme.textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
for (final id in optionIds)
|
||||||
|
DropdownMenuItem(value: id, child: Text('#$id')),
|
||||||
|
],
|
||||||
|
onChanged: _running
|
||||||
|
? null
|
||||||
|
: (v) => setState(() {
|
||||||
|
final next = [..._slots];
|
||||||
|
next[i] = v ?? 0;
|
||||||
|
_slots = next;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: _running ? null : _copySlot0ToOthers,
|
||||||
|
icon: const Icon(Icons.copy_all_outlined, size: 18),
|
||||||
|
label: const Text(AppStrings.travelPartnerCopySlot0),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _ownedCard(ThemeData theme) {
|
||||||
|
return _sectionCard(
|
||||||
|
theme,
|
||||||
|
icon: Icons.workspace_premium_outlined,
|
||||||
|
title:
|
||||||
|
'${AppStrings.travelPartnerOwnedTitle} (${_owned.length})',
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
if (_owned.isEmpty)
|
||||||
|
Text(
|
||||||
|
AppStrings.travelPartnerOwnedEmpty,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 6,
|
||||||
|
children: [
|
||||||
|
for (final c in _owned)
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 10,
|
||||||
|
vertical: 4,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.surfaceContainerHighest
|
||||||
|
.withValues(alpha: 0.5),
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'#${c.characterId} Lv${c.level}',
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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.travelPartnerRunning : AppStrings.travelPartnerRun,
|
||||||
|
),
|
||||||
|
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.travelPartnerStepFetch),
|
||||||
|
_stepRow(theme, _Step.upload, AppStrings.travelPartnerStepUpload),
|
||||||
|
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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ class UnlockMusicPage extends StatelessWidget {
|
|||||||
final int? loginDateTime;
|
final int? loginDateTime;
|
||||||
final int? loginId;
|
final int? loginId;
|
||||||
final String? lastLoginDate;
|
final String? lastLoginDate;
|
||||||
final Future<void> Function()? onLogoutRequested;
|
final Future<void> Function()? onExitToTitle;
|
||||||
|
|
||||||
const UnlockMusicPage({
|
const UnlockMusicPage({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -20,7 +20,7 @@ class UnlockMusicPage extends StatelessWidget {
|
|||||||
this.loginDateTime,
|
this.loginDateTime,
|
||||||
this.loginId,
|
this.loginId,
|
||||||
this.lastLoginDate,
|
this.lastLoginDate,
|
||||||
this.onLogoutRequested,
|
this.onExitToTitle,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -35,7 +35,7 @@ class UnlockMusicPage extends StatelessWidget {
|
|||||||
loginDateTime: loginDateTime,
|
loginDateTime: loginDateTime,
|
||||||
loginId: loginId,
|
loginId: loginId,
|
||||||
lastLoginDate: lastLoginDate,
|
lastLoginDate: lastLoginDate,
|
||||||
onLogoutRequested: onLogoutRequested,
|
onExitToTitle: onExitToTitle,
|
||||||
mode: MusicRiskFeatureMode.unlockMusic,
|
mode: MusicRiskFeatureMode.unlockMusic,
|
||||||
featureTitle: AppStrings.unlockFeatureTitle,
|
featureTitle: AppStrings.unlockFeatureTitle,
|
||||||
featureDesc: AppStrings.unlockFeatureDesc,
|
featureDesc: AppStrings.unlockFeatureDesc,
|
||||||
|
|||||||
@@ -9,6 +9,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_preview.dart';
|
import '../models/user_preview.dart';
|
||||||
import '../models/user_rating.dart';
|
import '../models/user_rating.dart';
|
||||||
|
|
||||||
@@ -557,6 +558,16 @@ class TitleApiService {
|
|||||||
final list = json['userCharacterList'] as List<dynamic>? ?? [];
|
final list = json['userCharacterList'] as List<dynamic>? ?? [];
|
||||||
return list.map((e) => e as Map<String, dynamic>).toList();
|
return list.map((e) => e as Map<String, dynamic>).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 旅行伙伴(角色)列表,对应 UserCharacterResponseVO。
|
||||||
|
Future<List<UserCharacterBean>> getUserCharacters(int userId) async {
|
||||||
|
final json = await _callApi(
|
||||||
|
'GetUserCharacterApi',
|
||||||
|
{'userId': userId},
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
return UserCharacterBean.listFromResponse(json);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _RandomHelper {
|
class _RandomHelper {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import '../config/title_server_config.dart';
|
import '../config/title_server_config.dart';
|
||||||
|
import '../models/user_character.dart';
|
||||||
import 'title_api_service.dart';
|
import 'title_api_service.dart';
|
||||||
|
|
||||||
/// Dart port of `eaquira/src/sdgb/payload.py::UserAll_payload` and
|
/// Dart port of `eaquira/src/sdgb/payload.py::UserAll_payload` and
|
||||||
@@ -356,27 +357,46 @@ class UserAllPayloadBuilder {
|
|||||||
upsert['isNewItemList'] = List.filled(items.length, '1').join();
|
upsert['isNewItemList'] = List.filled(items.length, '1').join();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Port of `music_user_all_patcher`: append unlock items
|
/// 解锁歌曲/谱面时补一条 musicDetail 记录并标记为新增。
|
||||||
/// (itemKind 5=music, 6=master, 7=remaster) to the packet.
|
/// 难度本身由 userItemList 的 itemKind 5/6/7 行表达(见客户端
|
||||||
void applyMusicUnlockPatch(
|
/// VOExtensions.ExportUserItems),不写在 musicDetail 上。
|
||||||
|
void applyMusicDetailPatch(
|
||||||
Map<String, dynamic> packet, {
|
Map<String, dynamic> packet, {
|
||||||
required Map<String, dynamic> musicData,
|
required Map<String, dynamic> musicData,
|
||||||
required int musicId,
|
|
||||||
bool unlockMusic = false,
|
|
||||||
bool unlockMaster = false,
|
|
||||||
bool unlockRemaster = false,
|
|
||||||
}) {
|
}) {
|
||||||
applyItemListPatch(packet, items: [
|
|
||||||
if (unlockMusic)
|
|
||||||
{'itemKind': 5, 'itemId': musicId, 'stock': 1, 'isValid': true},
|
|
||||||
if (unlockMaster)
|
|
||||||
{'itemKind': 6, 'itemId': musicId, 'stock': 1, 'isValid': true},
|
|
||||||
if (unlockRemaster)
|
|
||||||
{'itemKind': 7, 'itemId': musicId, 'stock': 1, 'isValid': true},
|
|
||||||
]);
|
|
||||||
|
|
||||||
final upsert = packet['upsertUserAll'] as Map<String, dynamic>;
|
final upsert = packet['upsertUserAll'] as Map<String, dynamic>;
|
||||||
upsert['userMusicDetailList'] = [musicData];
|
upsert['userMusicDetailList'] = [musicData];
|
||||||
upsert['isNewMusicDetailList'] = '1';
|
upsert['isNewMusicDetailList'] = '1';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 发放/更新旅行伙伴:写 `userCharacterList` 并按行数生成 `isNewCharacterList`。
|
||||||
|
/// 客户端 BuildListData 只发增量、每位一个字符,所以这里只放要新增的行。
|
||||||
|
void applyCharacterListPatch(
|
||||||
|
Map<String, dynamic> packet, {
|
||||||
|
required List<Map<String, dynamic>> characters,
|
||||||
|
}) {
|
||||||
|
final upsert = packet['upsertUserAll'] as Map<String, dynamic>;
|
||||||
|
upsert['userCharacterList'] = characters;
|
||||||
|
upsert['isNewCharacterList'] = List.filled(characters.length, '1').join();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 编组旅行伙伴:改 `userData[0].charaSlot`(int[5],槽 0 为队长)。
|
||||||
|
/// 客户端 ExportUserPlaylog 用同一份 CharaSlot 填 playlog 的
|
||||||
|
/// characterId1..5,这里一起同步,避免 playlog 与 userData 互相矛盾。
|
||||||
|
void applyCharaSlotPatch(
|
||||||
|
Map<String, dynamic> packet, {
|
||||||
|
required List<int> charaSlot,
|
||||||
|
}) {
|
||||||
|
final slots = normalizeCharaSlot(charaSlot);
|
||||||
|
final upsert = packet['upsertUserAll'] as Map<String, dynamic>;
|
||||||
|
final userDataList = upsert['userData'] as List;
|
||||||
|
(userDataList.first as Map<String, dynamic>)['charaSlot'] = slots;
|
||||||
|
|
||||||
|
for (final raw in (packet['userPlaylogList'] as List)) {
|
||||||
|
final playlog = raw as Map<String, dynamic>;
|
||||||
|
for (var i = 0; i < 5; i++) {
|
||||||
|
playlog['characterId${i + 1}'] = slots[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -128,8 +128,9 @@ void main() {
|
|||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: InteractiveViewer(
|
child: InteractiveViewer(
|
||||||
|
constrained: false,
|
||||||
child: FittedBox(
|
child: FittedBox(
|
||||||
fit: BoxFit.contain,
|
fit: BoxFit.fitWidth,
|
||||||
child: Best50Poster(
|
child: Best50Poster(
|
||||||
userName: 'Test Player',
|
userName: 'Test Player',
|
||||||
iconId: 0,
|
iconId: 0,
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
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/music_risk_feature_view.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
Future<void> _seedConfig() async {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
await TitleServerConfigHolder().update(
|
||||||
|
const TitleServerConfig(
|
||||||
|
titleServerUrl: 'http://127.0.0.1:9999',
|
||||||
|
aesKey: '0123456789abcdef',
|
||||||
|
aesIv: '0123456789abcdef',
|
||||||
|
clientId: 'A63E01C2805',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pumpView(WidgetTester tester, MusicRiskFeatureMode mode) async {
|
||||||
|
await _seedConfig();
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: MusicRiskFeatureView(
|
||||||
|
userId: 123,
|
||||||
|
cookies: 'JSESSIONID=X',
|
||||||
|
loginDateTime: 1,
|
||||||
|
loginId: 2,
|
||||||
|
mode: mode,
|
||||||
|
featureTitle: 't',
|
||||||
|
featureDesc: 'd',
|
||||||
|
onExitToTitle: () async {},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _addById(WidgetTester tester, String label, String id) async {
|
||||||
|
await tester.enterText(find.widgetWithText(TextField, label), id);
|
||||||
|
await tester.tap(find.text(AppStrings.listAddButton).first);
|
||||||
|
await tester.pump();
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
setUp(() => addTearDown(TitleServerConfigHolder().clear));
|
||||||
|
|
||||||
|
testWidgets('收藏品列表可累加多行并可移除', (tester) async {
|
||||||
|
await _pumpView(tester, MusicRiskFeatureMode.collectibles);
|
||||||
|
|
||||||
|
expect(find.textContaining(AppStrings.collectiblesPendingEmpty), findsOne);
|
||||||
|
|
||||||
|
await _addById(tester, AppStrings.collectiblesItemIdLabel, '250103');
|
||||||
|
await _addById(tester, AppStrings.collectiblesItemIdLabel, '30021');
|
||||||
|
|
||||||
|
expect(find.textContaining('#250103'), findsOneWidget);
|
||||||
|
expect(find.textContaining('#30021'), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
find.textContaining('${AppStrings.collectiblesPendingTitle} (2)'),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 同一类型同一 ID 不应重复入列。
|
||||||
|
await _addById(tester, AppStrings.collectiblesItemIdLabel, '30021');
|
||||||
|
expect(
|
||||||
|
find.textContaining('${AppStrings.collectiblesPendingTitle} (2)'),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.tap(find.byTooltip(AppStrings.listRemoveTooltip).first);
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.textContaining('#250103'), findsNothing);
|
||||||
|
expect(
|
||||||
|
find.textContaining('${AppStrings.collectiblesPendingTitle} (1)'),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('解锁列表按勾选展开难度且添加后清空输入', (tester) async {
|
||||||
|
await _pumpView(tester, MusicRiskFeatureMode.unlockMusic);
|
||||||
|
|
||||||
|
await tester.enterText(
|
||||||
|
find.widgetWithText(TextField, AppStrings.unlockMusicIdLabel),
|
||||||
|
'834',
|
||||||
|
);
|
||||||
|
await tester.tap(find.text(AppStrings.unlockMusicOption).first);
|
||||||
|
await tester.tap(find.text(AppStrings.unlockMasterOption).first);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.text(AppStrings.listAddButton).first);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
final row = find.textContaining('#834');
|
||||||
|
expect(row, findsOneWidget);
|
||||||
|
expect(tester.widget<Text>(row.first).data, contains('Master'));
|
||||||
|
// 添加后输入与勾选应被清空,方便连续添加下一条。
|
||||||
|
expect(
|
||||||
|
tester.widget<TextField>(
|
||||||
|
find.widgetWithText(TextField, AppStrings.unlockMusicIdLabel),
|
||||||
|
).controller!.text,
|
||||||
|
isEmpty,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('自动退出登录并返回标题页默认勾选', (tester) async {
|
||||||
|
await _pumpView(tester, MusicRiskFeatureMode.collectibles);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
find.text(AppStrings.autoLogoutAndExit),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
// 未勾选任何解锁项时,唯一处于选中态的复选框就是自动退登。
|
||||||
|
final checked = tester
|
||||||
|
.widgetList<Checkbox>(find.byType(Checkbox))
|
||||||
|
.where((c) => c.value == true)
|
||||||
|
.toList();
|
||||||
|
expect(checked, hasLength(1));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
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/travel_partner_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 = loggedIn ? const Size(420, 1500) : const Size(1440, 900);
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: TravelPartnerPage(
|
||||||
|
userId: 42,
|
||||||
|
cookies: 'JSESSIONID=X',
|
||||||
|
loginDateTime: loggedIn ? 1 : null,
|
||||||
|
loginId: loggedIn ? 7 : null,
|
||||||
|
onExitToTitle: () async {},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('已登录时渲染 5 个槽位与一键复制,且无溢出', (tester) async {
|
||||||
|
await _pumpPage(tester, loggedIn: true);
|
||||||
|
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
for (var i = 0; i < 5; i++) {
|
||||||
|
expect(
|
||||||
|
find.text(AppStrings.travelPartnerSlotLabel(i)),
|
||||||
|
findsOneWidget,
|
||||||
|
reason: '缺少槽位 $i 的下拉框',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
expect(find.text(AppStrings.travelPartnerCopySlot0), findsOneWidget);
|
||||||
|
expect(find.text(AppStrings.travelPartnerGrantTitle), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('未登录时给出提示并禁用执行', (tester) async {
|
||||||
|
await _pumpPage(tester, loggedIn: false);
|
||||||
|
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
expect(find.text(AppStrings.travelPartnerNotLoggedIn), findsOneWidget);
|
||||||
|
|
||||||
|
final run = tester.widget<FilledButton>(
|
||||||
|
find.ancestor(
|
||||||
|
of: find.text(AppStrings.travelPartnerRun),
|
||||||
|
matching: find.byType(FilledButton),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(run.onPressed, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('槽 0 未选择时复制按钮不改动槽位', (tester) async {
|
||||||
|
await _pumpPage(tester, loggedIn: true);
|
||||||
|
|
||||||
|
await tester.tap(find.text(AppStrings.travelPartnerCopySlot0));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
// 未拥有任何角色时,复制应被拦下并提示,而不是写入非法槽位。
|
||||||
|
expect(find.text(AppStrings.travelPartnerCopyNeedSlot0), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:ichino/config/title_server_config.dart';
|
||||||
|
import 'package:ichino/models/user_character.dart';
|
||||||
|
import 'package:ichino/services/user_all_payload_builder.dart';
|
||||||
|
|
||||||
|
const _config = TitleServerConfig(
|
||||||
|
titleServerUrl: 'http://127.0.0.1:9999',
|
||||||
|
aesKey: '0123456789abcdef',
|
||||||
|
aesIv: '0123456789abcdef',
|
||||||
|
clientId: 'A63E01C2805',
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _buildPacket(UserAllPayloadBuilder builder) =>
|
||||||
|
builder.build(
|
||||||
|
userId: 42,
|
||||||
|
loginId: 7,
|
||||||
|
loginDateTime: 1700000000,
|
||||||
|
musicData: const {
|
||||||
|
'musicId': 11538,
|
||||||
|
'level': 0,
|
||||||
|
'playCount': 1,
|
||||||
|
'achievement': 0,
|
||||||
|
'comboStatus': 0,
|
||||||
|
'syncStatus': 0,
|
||||||
|
'deluxscoreMax': 0,
|
||||||
|
'scoreRank': 0,
|
||||||
|
'extNum1': 0,
|
||||||
|
},
|
||||||
|
generalUserInfo: const {},
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _upsert(Map<String, dynamic> packet) =>
|
||||||
|
packet['upsertUserAll'] as Map<String, dynamic>;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('旅行伙伴 userCharacterList patch', () {
|
||||||
|
test('写入 4 字段行并按行数生成 isNewCharacterList', () {
|
||||||
|
final builder = UserAllPayloadBuilder(_config);
|
||||||
|
final packet = _buildPacket(builder);
|
||||||
|
|
||||||
|
builder.applyCharacterListPatch(packet, characters: [
|
||||||
|
{'characterId': 1001, 'level': 1, 'awakening': 0, 'useCount': 0},
|
||||||
|
{'characterId': 1002, 'level': 3, 'awakening': 1, 'useCount': 9},
|
||||||
|
]);
|
||||||
|
|
||||||
|
final upsert = _upsert(packet);
|
||||||
|
expect(upsert['userCharacterList'], hasLength(2));
|
||||||
|
expect(upsert['isNewCharacterList'], '11');
|
||||||
|
final first = (upsert['userCharacterList'] as List).first
|
||||||
|
as Map<String, dynamic>;
|
||||||
|
// 客户端 UserCharacter 结构只有这 4 个字段,多一个都可能被拒。
|
||||||
|
expect(first.keys.toSet(), {
|
||||||
|
'characterId',
|
||||||
|
'level',
|
||||||
|
'awakening',
|
||||||
|
'useCount',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('空列表时 isNewCharacterList 为空串', () {
|
||||||
|
final builder = UserAllPayloadBuilder(_config);
|
||||||
|
final packet = _buildPacket(builder);
|
||||||
|
builder.applyCharacterListPatch(packet, characters: []);
|
||||||
|
expect(_upsert(packet)['isNewCharacterList'], '');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('charaSlot 编组 patch', () {
|
||||||
|
test('槽位补齐到 5 并同步 playlog 的 characterId1..5', () {
|
||||||
|
final builder = UserAllPayloadBuilder(_config);
|
||||||
|
final packet = _buildPacket(builder);
|
||||||
|
|
||||||
|
builder.applyCharaSlotPatch(packet, charaSlot: const [1001]);
|
||||||
|
|
||||||
|
final userData = (_upsert(packet)['userData'] as List).first
|
||||||
|
as Map<String, dynamic>;
|
||||||
|
expect(userData['charaSlot'], [1001, 0, 0, 0, 0]);
|
||||||
|
|
||||||
|
final playlog = (packet['userPlaylogList'] as List).first
|
||||||
|
as Map<String, dynamic>;
|
||||||
|
expect(playlog['characterId1'], 1001);
|
||||||
|
expect(playlog['characterId2'], 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('超过 5 个槽位被截断', () {
|
||||||
|
expect(
|
||||||
|
normalizeCharaSlot(const [1, 2, 3, 4, 5, 6, 7]),
|
||||||
|
[1, 2, 3, 4, 5],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('一键复制槽 0 到其余槽位', () {
|
||||||
|
final slots = normalizeCharaSlot(const [1001, 0, 0, 0, 0]);
|
||||||
|
final copied = normalizeCharaSlot([
|
||||||
|
for (var i = 0; i < 5; i++) slots[0],
|
||||||
|
]);
|
||||||
|
expect(copied, [1001, 1001, 1001, 1001, 1001]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('收藏品 userItemList patch', () {
|
||||||
|
test('多行道具与 isNewItemList 位数一致', () {
|
||||||
|
final builder = UserAllPayloadBuilder(_config);
|
||||||
|
final packet = _buildPacket(builder);
|
||||||
|
|
||||||
|
builder.applyItemListPatch(packet, items: [
|
||||||
|
{'itemKind': 9, 'itemId': 1001, 'stock': 1, 'isValid': true},
|
||||||
|
{'itemKind': 10, 'itemId': 2002, 'stock': 1, 'isValid': true},
|
||||||
|
]);
|
||||||
|
|
||||||
|
final upsert = _upsert(packet);
|
||||||
|
expect(upsert['userItemList'], hasLength(2));
|
||||||
|
expect(upsert['isNewItemList'], '11');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in new issue
Block a user