diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..64144d4 --- /dev/null +++ b/.github/workflows/build.yml @@ -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 diff --git a/.gitignore b/.gitignore index 3820a95..941eba4 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,6 @@ app.*.map.json /android/app/debug /android/app/profile /android/app/release + +# Flutter DevTools 本地配置 +devtools_options.yaml diff --git a/README.md b/README.md index c1b2c6c..632bd8a 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,104 @@ # 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 diff --git a/lib/config/responsive.dart b/lib/config/responsive.dart index 4dbc6bf..01ff07f 100644 --- a/lib/config/responsive.dart +++ b/lib/config/responsive.dart @@ -23,6 +23,18 @@ bool isTablet(BuildContext context) => bool isDesktop(BuildContext context) => 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; /// on tablet+ they are laid out in [columns] columns. List responsiveGrid({ diff --git a/lib/config/strings.dart b/lib/config/strings.dart index 6f62302..8c37f58 100644 --- a/lib/config/strings.dart +++ b/lib/config/strings.dart @@ -174,6 +174,19 @@ class AppStrings { static const unlockNeedMusicId = '请填写要解锁的歌曲 ID。'; static const unlockNeedOption = '请至少勾选一项解锁内容。'; 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) => '登录后需冷却 $ticketCooldownSeconds 秒,剩余 $remaining 秒后可执行解锁操作。'; static const unlockFetchData = '拉取数据'; @@ -181,7 +194,6 @@ class AppStrings { static const unlockRefetch = '重新拉取'; static const unlockNoData = '⚠️ 未拉取到用户数据,请先点「拉取数据」按钮获取。'; static const unlockFetched = '数据已拉取'; - static const unlockAutoLogout = '完成后自动退出登录'; static const unlockMusicRun = '执行解锁'; static const unlockRunning = '执行中...'; 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 (中转页) static const musicRiskHubTitle = '高危功能'; static const musicRiskHubDesc = '以下功能会向服务器发送伪造数据,属于高风险操作,请谨慎使用。'; diff --git a/lib/main.dart b/lib/main.dart index 5da6d31..81b30d4 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -158,8 +158,8 @@ class _LoginPageState extends State { body: Center( child: SingleChildScrollView( padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 48), - child: ConstrainedBox( - constraints: BoxConstraints(maxWidth: responsiveMaxWidth(context)), + child: responsiveBody( + context, child: Column( mainAxisSize: MainAxisSize.min, children: [ diff --git a/lib/models/user_character.dart b/lib/models/user_character.dart new file mode 100644 index 0000000..093b5fb --- /dev/null +++ b/lib/models/user_character.dart @@ -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 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 json) => + UserCharacterBean( + characterId: _toInt(json['characterId']), + level: _toInt(json['level']), + awakening: _toInt(json['awakening']), + useCount: _toInt(json['useCount']), + ); + + static List listFromResponse(Map json) { + final raw = json['userCharacterList']; + if (raw is! List) return const []; + return raw + .whereType>() + .map(UserCharacterBean.fromJson) + .toList(); + } +} + +/// `UserDetail.charaSlot` 固定 5 个槽,槽 0 是队长 +/// (客户端 CharactorSlotController.cs:27 对 i==0 用 leaderCharaSlotPrefab)。 +List normalizeCharaSlot(List slots) => + List.generate(5, (i) => i < slots.length ? slots[i] : 0); diff --git a/lib/pages/about_page.dart b/lib/pages/about_page.dart index 4dafd9c..217a19d 100644 --- a/lib/pages/about_page.dart +++ b/lib/pages/about_page.dart @@ -12,8 +12,8 @@ class AboutPage extends StatelessWidget { return SingleChildScrollView( padding: const EdgeInsets.all(24), - child: ConstrainedBox( - constraints: BoxConstraints(maxWidth: responsiveMaxWidth(context)), + child: responsiveBody( + context, child: Column( children: [ const SizedBox(height: 32), diff --git a/lib/pages/best50_page.dart b/lib/pages/best50_page.dart index 8279d76..353a63a 100644 --- a/lib/pages/best50_page.dart +++ b/lib/pages/best50_page.dart @@ -3,6 +3,7 @@ import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; +import '../config/responsive.dart'; import '../config/strings.dart'; import '../config/title_server_config.dart'; import '../models/user_rating.dart'; @@ -189,56 +190,80 @@ class _Best50PageState extends State { return Scaffold( body: Column( children: [ - if (!_musicReady) - _NoticeBanner(theme: theme, text: AppStrings.best50NoMusicData), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: SizedBox( - width: double.infinity, - child: FilledButton.icon( - onPressed: _exporting ? null : _export, - icon: _exporting - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : 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), + responsiveBody( + context, + child: Column( + children: [ + if (!_musicReady) + _NoticeBanner( + theme: theme, + text: AppStrings.best50NoMusicData, + ), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + child: SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: _exporting ? null : _export, + icon: _exporting + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : 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( - child: InteractiveViewer( - maxScale: 6, - boundaryMargin: const EdgeInsets.all(24), - // FittedBox 会按视口把固定尺寸的海报缩出来;它给子节点无限约束, - // 所以海报仍保持 1762x1850,RepaintBoundary 录到的也是原始尺寸 - // (祖先变换不会被烘进边界里),导出不会糊。 - child: FittedBox( - fit: BoxFit.contain, - alignment: Alignment.topCenter, - child: RepaintBoundary( - key: _posterKey, - child: Best50Poster( - userName: widget.userName, - iconId: widget.iconId, - playerRating: widget.playerRating, - best35: best35, - best15: best15, + child: LayoutBuilder( + builder: (context, constraints) { + // 按视口宽度适配:横屏/桌面下海报占满宽度、竖向拖动看全图, + // 而不是被 contain 缩成中间一小块、两侧大片空白。 + // minScale 再允许缩到整张可见,导出仍是原始 1762x1850。 + final fitWidth = + constraints.maxWidth / Best50Poster.canvasWidth; + final scaledHeight = Best50Poster.canvasHeight * fitWidth; + final containScale = constraints.maxHeight / scaledHeight; + final minScale = containScale < 1 ? containScale : 1.0; + + return InteractiveViewer( + constrained: false, + minScale: minScale, + maxScale: minScale * 6, + boundaryMargin: const EdgeInsets.all(24), + child: FittedBox( + fit: BoxFit.fitWidth, + child: RepaintBoundary( + key: _posterKey, + child: Best50Poster( + userName: widget.userName, + iconId: widget.iconId, + playerRating: widget.playerRating, + best35: best35, + best15: best15, + ), + ), ), - ), - ), + ); + }, ), ), ], diff --git a/lib/pages/collectibles_page.dart b/lib/pages/collectibles_page.dart index ab34177..45d5ddb 100644 --- a/lib/pages/collectibles_page.dart +++ b/lib/pages/collectibles_page.dart @@ -13,7 +13,7 @@ class CollectiblesPage extends StatelessWidget { final int? loginDateTime; final int? loginId; final String? lastLoginDate; - final Future Function()? onLogoutRequested; + final Future Function()? onExitToTitle; const CollectiblesPage({ super.key, @@ -22,7 +22,7 @@ class CollectiblesPage extends StatelessWidget { this.loginDateTime, this.loginId, this.lastLoginDate, - this.onLogoutRequested, + this.onExitToTitle, }); @override @@ -37,7 +37,7 @@ class CollectiblesPage extends StatelessWidget { loginDateTime: loginDateTime, loginId: loginId, lastLoginDate: lastLoginDate, - onLogoutRequested: onLogoutRequested, + onExitToTitle: onExitToTitle, mode: MusicRiskFeatureMode.collectibles, featureTitle: AppStrings.collectiblesFeatureTitle, featureDesc: AppStrings.collectiblesFeatureDesc, diff --git a/lib/pages/config_export_import_page.dart b/lib/pages/config_export_import_page.dart index 6a18ee0..eed2941 100644 --- a/lib/pages/config_export_import_page.dart +++ b/lib/pages/config_export_import_page.dart @@ -74,8 +74,8 @@ class ConfigExportImportPage extends StatelessWidget { body: Center( child: Padding( padding: const EdgeInsets.all(24), - child: ConstrainedBox( - constraints: BoxConstraints(maxWidth: responsiveMaxWidth(context)), + child: responsiveBody( + context, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, diff --git a/lib/pages/high_risk_feature_page.dart b/lib/pages/high_risk_feature_page.dart index e91466e..855e268 100644 --- a/lib/pages/high_risk_feature_page.dart +++ b/lib/pages/high_risk_feature_page.dart @@ -6,6 +6,7 @@ import '../config/responsive.dart'; import '../config/strings.dart'; import '../config/title_server_config.dart'; import 'collectibles_page.dart'; +import 'travel_partner_page.dart'; import 'unlock_music_page.dart'; /// 高危功能中转页:导航到 UnlockMusic (解锁歌曲) / 收藏品获取。 @@ -22,8 +23,8 @@ class HighRiskFeaturePage extends StatefulWidget { final int? loginId; final String? lastLoginDate; - /// 由 HomePage 负责发送 logout 包。 - final Future Function()? onLogoutRequested; + /// 完成后自动退出登录并返回标题页,由 HomePage 执行结算+退登+pop。 + final Future Function()? onExitToTitle; const HighRiskFeaturePage({ super.key, @@ -32,7 +33,7 @@ class HighRiskFeaturePage extends StatefulWidget { this.loginDateTime, this.loginId, this.lastLoginDate, - this.onLogoutRequested, + this.onExitToTitle, }); @override @@ -99,7 +100,7 @@ class _HighRiskFeaturePageState extends State { loginDateTime: widget.loginDateTime, loginId: widget.loginId, lastLoginDate: widget.lastLoginDate, - onLogoutRequested: widget.onLogoutRequested, + onExitToTitle: widget.onExitToTitle, ), ), ); @@ -114,7 +115,21 @@ class _HighRiskFeaturePageState extends State { loginDateTime: widget.loginDateTime, loginId: widget.loginId, 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 { return SingleChildScrollView( padding: const EdgeInsets.all(16), - child: ConstrainedBox( - constraints: BoxConstraints(maxWidth: responsiveMaxWidth(context)), + child: responsiveBody( + context, child: Column( children: [ if (widget.loginDateTime == null) _buildNotLoggedInBanner(theme), @@ -168,6 +183,14 @@ class _HighRiskFeaturePageState extends State { desc: AppStrings.collectiblesFeatureDesc, onTap: _openCollectibles, ), + const SizedBox(height: 12), + _buildNavCard( + theme, + icon: Icons.workspace_premium_outlined, + title: AppStrings.travelPartnerFeatureTitle, + desc: AppStrings.travelPartnerFeatureDesc, + onTap: _openTravelPartner, + ), ], ), ), diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index 5dc1139..369a38a 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -177,7 +177,10 @@ class _HomePageState extends State { } _session.reset(); 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 { loginDateTime: _session.gameLogin?.loginDateTime, loginId: _session.gameLogin?.loginId, lastLoginDate: _session.gameLogin?.lastLoginDate, - onLogoutRequested: - _session.gameLogin != null ? _logoutSession : null, + onExitToTitle: + _session.gameLogin != null ? _performLogoutAndExit : null, ), SettingsPage(showAppBar: false), const AboutPage(), @@ -357,8 +360,8 @@ class _HomePageState extends State { }) { return SingleChildScrollView( padding: const EdgeInsets.all(16), - child: ConstrainedBox( - constraints: BoxConstraints(maxWidth: responsiveMaxWidth(context)), + child: responsiveBody( + context, child: Column( children: [ if (preview.errorId != 0) _ErrorIdBanner(theme: theme, errorId: preview.errorId), @@ -429,8 +432,8 @@ class _HomePageState extends State { ) { return SingleChildScrollView( padding: const EdgeInsets.all(16), - child: ConstrainedBox( - constraints: BoxConstraints(maxWidth: responsiveMaxWidth(context)), + child: responsiveBody( + context, child: Column( children: [ _ProfileFromUserDataCard(theme: theme, data: data), diff --git a/lib/pages/music_risk_feature_view.dart b/lib/pages/music_risk_feature_view.dart index 57a9af0..35c3969 100644 --- a/lib/pages/music_risk_feature_view.dart +++ b/lib/pages/music_risk_feature_view.dart @@ -25,6 +25,39 @@ enum MusicRiskFeatureMode { collectibles, } +/// 一行待提交的收藏品道具(对应 wire 上的 UserItem)。 +class _PendingItem { + final int kind; + final int id; + + const _PendingItem(this.kind, this.id); +} + +/// 一行待提交的歌曲解锁,展开成 itemKind 5/6/7 的若干行。 +class _PendingMusic { + final int musicId; + final bool base; + final bool master; + final bool remaster; + + const _PendingMusic(this.musicId, this.base, this.master, this.remaster); + + bool get hasAnyOption => base || master || remaster; + + /// 难度由 itemKind 表达,不写在 musicDetail 上。 + List get itemKinds => [ + if (base) 5, + if (master) 6, + if (remaster) 7, + ]; + + String get optionLabel => [ + if (base) '歌曲', + if (master) 'Master', + if (remaster) 'Re:Master', + ].join('/'); +} + class MusicRiskFeatureView extends StatefulWidget { final int userId; final String? cookies; @@ -37,8 +70,9 @@ class MusicRiskFeatureView extends StatefulWidget { final int? loginId; final String? lastLoginDate; - /// 当用户勾选"完成后自动退出登录"时,由 HomePage 负责发送 logout 包。 - final Future Function()? onLogoutRequested; + /// 完成后自动退出登录并返回标题页时调用;由 HomePage 提供, + /// 内部会等待结算、发 UserLogoutApi、重置会话并 pop 回主标题。 + final Future Function()? onExitToTitle; final MusicRiskFeatureMode mode; final String featureTitle; @@ -51,7 +85,7 @@ class MusicRiskFeatureView extends StatefulWidget { this.loginDateTime, this.loginId, this.lastLoginDate, - this.onLogoutRequested, + this.onExitToTitle, this.mode = MusicRiskFeatureMode.unlockMusic, required this.featureTitle, required this.featureDesc, @@ -75,12 +109,19 @@ class _MusicRiskFeatureViewState extends State { final _itemIdController = TextEditingController(); 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>? _userAllData; bool _fetching = false; String? _fetchError; bool _running = false; - bool _autoLogout = false; + bool _autoLogout = true; MusicRiskStep _step = MusicRiskStep.idle; String _stepMessage = ''; String? _error; @@ -153,10 +194,63 @@ class _MusicRiskFeatureViewState extends State { 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。 + /// 解锁状态本身由 userItemList 的 itemKind 5/6/7 行表达,这里只保留一条 + /// 记录以维持 payload 形状,取列表首条歌曲 ID。 Map _buildMusicData() { return { - 'musicId': _isUnlock ? _unlockMusicId() : _defaultMusicId, + 'musicId': _isUnlock + ? (_pendingMusics.isNotEmpty + ? _pendingMusics.first.musicId + : _defaultMusicId) + : _defaultMusicId, 'level': 0, 'playCount': 1, 'achievement': 0, @@ -168,16 +262,38 @@ class _MusicRiskFeatureViewState extends State { }; } + /// 汇总成 wire 上的 userItemList 行。 + List> _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() { if (_isUnlock) { - if (_unlockMusicId() <= 0) return AppStrings.unlockNeedMusicId; - if (!_unlockMusic && !_unlockMaster && !_unlockRemaster) { - return AppStrings.unlockNeedOption; - } + if (_pendingMusics.isEmpty) return AppStrings.unlockNeedList; return null; } - if (_itemId() <= 0) return AppStrings.collectiblesNeedItemId; + if (_pendingItems.isEmpty) return AppStrings.collectiblesNeedList; return null; } @@ -264,35 +380,27 @@ class _MusicRiskFeatureViewState extends State { generalUserInfo: data, ); + builder.applyItemListPatch(packet, items: _buildUserItemList()); if (_isUnlock) { - builder.applyMusicUnlockPatch( - packet, - musicData: musicData, - musicId: _unlockMusicId(), - unlockMusic: _unlockMusic, - unlockMaster: _unlockMaster, - unlockRemaster: _unlockRemaster, - ); - } else { - builder.applyItemListPatch(packet, items: [ - {'itemKind': _itemKind, 'itemId': _itemId(), 'stock': 1, 'isValid': true}, - ]); + builder.applyMusicDetailPatch(packet, musicData: musicData); } 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( MusicRiskStep.complete, _isUnlock ? AppStrings.unlockMusicSuccess : 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) { _updateStep(MusicRiskStep.failed, e.message); setState(() => _error = e.message); @@ -336,8 +444,8 @@ class _MusicRiskFeatureViewState extends State { return SingleChildScrollView( padding: const EdgeInsets.all(16), - child: ConstrainedBox( - constraints: BoxConstraints(maxWidth: responsiveMaxWidth(context)), + child: responsiveBody( + context, child: Column( children: [ if (widget.loginDateTime == null) _buildNotLoggedInBanner(theme), @@ -512,12 +620,107 @@ class _MusicRiskFeatureViewState extends State { enabled: enabled, onChanged: (v) => setState(() => _unlockRemaster = v), ), + const SizedBox(height: 8), + _buildAddButton(theme, onPressed: enabled ? _addMusicToList : null), + _buildPendingList( + theme, + title: AppStrings.unlockPendingTitle, + emptyHint: AppStrings.unlockPendingEmpty, + count: _pendingMusics.length, + rows: [ + for (var i = 0; i < _pendingMusics.length; i++) + _buildPendingRow( + theme, + leading: '#${_pendingMusics[i].musicId}', + trailing: _pendingMusics[i].optionLabel, + onRemove: () => setState(() => _pendingMusics.removeAt(i)), + ), + ], + ), ], ), ), ); } + Widget _buildAddButton(ThemeData theme, {VoidCallback? onPressed}) { + return SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: onPressed, + icon: const Icon(Icons.add, size: 18), + label: const Text(AppStrings.listAddButton), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + ), + ); + } + + Widget _buildPendingList( + ThemeData theme, { + required String title, + required String emptyHint, + required int count, + required List rows, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 14), + Text( + '$title ($count)', + style: theme.textTheme.labelLarge?.copyWith( + color: theme.colorScheme.primary, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 6), + if (count == 0) + Text( + emptyHint, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ) + else + ...rows, + ], + ); + } + + Widget _buildPendingRow( + ThemeData theme, { + required String leading, + required String trailing, + required VoidCallback onRemove, + }) { + return Row( + children: [ + Expanded( + child: Text( + '$leading $trailing', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + fontFamily: 'monospace', + ), + ), + ), + IconButton( + icon: const Icon(Icons.close, size: 18), + tooltip: AppStrings.listRemoveTooltip, + onPressed: _running ? null : onRemove, + color: theme.colorScheme.onSurfaceVariant, + visualDensity: VisualDensity.compact, + ), + ], + ); + } + Widget _buildUnlockCheckbox( ThemeData theme, { required bool value, @@ -595,6 +798,24 @@ class _MusicRiskFeatureViewState extends State { 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 { } Widget _buildAutoLogoutToggle(ThemeData theme) { - final enabled = widget.onLogoutRequested != null && !_running; + final enabled = widget.onExitToTitle != null && !_running; return InkWell( borderRadius: BorderRadius.circular(10), onTap: enabled @@ -712,7 +933,7 @@ class _MusicRiskFeatureViewState extends State { ), Expanded( child: Text( - AppStrings.unlockAutoLogout, + AppStrings.autoLogoutAndExit, style: theme.textTheme.bodyMedium?.copyWith( color: enabled ? null diff --git a/lib/pages/settings_page.dart b/lib/pages/settings_page.dart index c10f3af..d6eda9b 100644 --- a/lib/pages/settings_page.dart +++ b/lib/pages/settings_page.dart @@ -107,8 +107,8 @@ class _SettingsPageState extends State { : null, body: SingleChildScrollView( padding: const EdgeInsets.all(20), - child: ConstrainedBox( - constraints: BoxConstraints(maxWidth: responsiveMaxWidth(context)), + child: responsiveBody( + context, child: Form( key: _formKey, child: Column( diff --git a/lib/pages/ticket_page.dart b/lib/pages/ticket_page.dart index 3e806e8..4efd67e 100644 --- a/lib/pages/ticket_page.dart +++ b/lib/pages/ticket_page.dart @@ -257,8 +257,8 @@ class _TicketPageState extends State { return SingleChildScrollView( padding: const EdgeInsets.all(16), - child: ConstrainedBox( - constraints: BoxConstraints(maxWidth: responsiveMaxWidth(context)), + child: responsiveBody( + context, child: Column( children: [ if (widget.loginDateTime == null) _buildNotLoggedInBanner(theme), diff --git a/lib/pages/travel_partner_page.dart b/lib/pages/travel_partner_page.dart new file mode 100644 index 0000000..1fd9f98 --- /dev/null +++ b/lib/pages/travel_partner_page.dart @@ -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 Function()? onExitToTitle; + + const TravelPartnerPage({ + super.key, + required this.userId, + this.cookies, + this.loginDateTime, + this.loginId, + this.onExitToTitle, + }); + + @override + State createState() => _TravelPartnerPageState(); +} + +enum _Step { idle, fetchData, upload, logout, complete, failed } + +class _TravelPartnerPageState extends State { + /// 与解锁/收藏品一致:这些操作不写真实成绩,playlog 沿用同一条占位记录。 + static const int _placeholderMusicId = 11538; + + final _grantIdController = TextEditingController(); + + List _owned = const []; + Map> _userAllData = const {}; + final List _pendingGrant = []; + List _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 _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; + final all = results[1] as Map>; + final userData = + (all['GetUserDataApi']?['userData'] as Map?) ?? + const {}; + final slots = (userData['charaSlot'] as List? ?? 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 _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 _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 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 = { + ..._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( + 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)), + ], + ), + ); + } +} diff --git a/lib/pages/unlock_music_page.dart b/lib/pages/unlock_music_page.dart index 5336cc0..b9989df 100644 --- a/lib/pages/unlock_music_page.dart +++ b/lib/pages/unlock_music_page.dart @@ -11,7 +11,7 @@ class UnlockMusicPage extends StatelessWidget { final int? loginDateTime; final int? loginId; final String? lastLoginDate; - final Future Function()? onLogoutRequested; + final Future Function()? onExitToTitle; const UnlockMusicPage({ super.key, @@ -20,7 +20,7 @@ class UnlockMusicPage extends StatelessWidget { this.loginDateTime, this.loginId, this.lastLoginDate, - this.onLogoutRequested, + this.onExitToTitle, }); @override @@ -35,7 +35,7 @@ class UnlockMusicPage extends StatelessWidget { loginDateTime: loginDateTime, loginId: loginId, lastLoginDate: lastLoginDate, - onLogoutRequested: onLogoutRequested, + onExitToTitle: onExitToTitle, mode: MusicRiskFeatureMode.unlockMusic, featureTitle: AppStrings.unlockFeatureTitle, featureDesc: AppStrings.unlockFeatureDesc, diff --git a/lib/services/title_api_service.dart b/lib/services/title_api_service.dart index 17fdc7b..03307c7 100644 --- a/lib/services/title_api_service.dart +++ b/lib/services/title_api_service.dart @@ -9,6 +9,7 @@ import 'package:pointycastle/export.dart'; import '../config/title_server_config.dart'; import '../models/user_data.dart'; +import '../models/user_character.dart'; import '../models/user_preview.dart'; import '../models/user_rating.dart'; @@ -557,6 +558,16 @@ class TitleApiService { final list = json['userCharacterList'] as List? ?? []; return list.map((e) => e as Map).toList(); } + + /// 旅行伙伴(角色)列表,对应 UserCharacterResponseVO。 + Future> getUserCharacters(int userId) async { + final json = await _callApi( + 'GetUserCharacterApi', + {'userId': userId}, + userId, + ); + return UserCharacterBean.listFromResponse(json); + } } class _RandomHelper { diff --git a/lib/services/user_all_payload_builder.dart b/lib/services/user_all_payload_builder.dart index 06ccf9b..c93008c 100644 --- a/lib/services/user_all_payload_builder.dart +++ b/lib/services/user_all_payload_builder.dart @@ -1,4 +1,5 @@ import '../config/title_server_config.dart'; +import '../models/user_character.dart'; import 'title_api_service.dart'; /// 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(); } - /// Port of `music_user_all_patcher`: append unlock items - /// (itemKind 5=music, 6=master, 7=remaster) to the packet. - void applyMusicUnlockPatch( + /// 解锁歌曲/谱面时补一条 musicDetail 记录并标记为新增。 + /// 难度本身由 userItemList 的 itemKind 5/6/7 行表达(见客户端 + /// VOExtensions.ExportUserItems),不写在 musicDetail 上。 + void applyMusicDetailPatch( Map packet, { required Map 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; upsert['userMusicDetailList'] = [musicData]; upsert['isNewMusicDetailList'] = '1'; } + + /// 发放/更新旅行伙伴:写 `userCharacterList` 并按行数生成 `isNewCharacterList`。 + /// 客户端 BuildListData 只发增量、每位一个字符,所以这里只放要新增的行。 + void applyCharacterListPatch( + Map packet, { + required List> characters, + }) { + final upsert = packet['upsertUserAll'] as Map; + 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 packet, { + required List charaSlot, + }) { + final slots = normalizeCharaSlot(charaSlot); + final upsert = packet['upsertUserAll'] as Map; + final userDataList = upsert['userData'] as List; + (userDataList.first as Map)['charaSlot'] = slots; + + for (final raw in (packet['userPlaylogList'] as List)) { + final playlog = raw as Map; + for (var i = 0; i < 5; i++) { + playlog['characterId${i + 1}'] = slots[i]; + } + } + } } diff --git a/test/best50_poster_test.dart b/test/best50_poster_test.dart index c53c98e..03dc0eb 100644 --- a/test/best50_poster_test.dart +++ b/test/best50_poster_test.dart @@ -128,8 +128,9 @@ void main() { children: [ Expanded( child: InteractiveViewer( + constrained: false, child: FittedBox( - fit: BoxFit.contain, + fit: BoxFit.fitWidth, child: Best50Poster( userName: 'Test Player', iconId: 0, diff --git a/test/music_risk_list_test.dart b/test/music_risk_list_test.dart new file mode 100644 index 0000000..7f2cbf3 --- /dev/null +++ b/test/music_risk_list_test.dart @@ -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 _seedConfig() async { + SharedPreferences.setMockInitialValues({}); + await TitleServerConfigHolder().update( + const TitleServerConfig( + titleServerUrl: 'http://127.0.0.1:9999', + aesKey: '0123456789abcdef', + aesIv: '0123456789abcdef', + clientId: 'A63E01C2805', + ), + ); +} + +Future _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 _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(row.first).data, contains('Master')); + // 添加后输入与勾选应被清空,方便连续添加下一条。 + expect( + tester.widget( + 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(find.byType(Checkbox)) + .where((c) => c.value == true) + .toList(); + expect(checked, hasLength(1)); + }); +} diff --git a/test/travel_partner_page_test.dart b/test/travel_partner_page_test.dart new file mode 100644 index 0000000..85d9974 --- /dev/null +++ b/test/travel_partner_page_test.dart @@ -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 _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( + 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); + }); +} diff --git a/test/user_all_patch_test.dart b/test/user_all_patch_test.dart new file mode 100644 index 0000000..cf2c77e --- /dev/null +++ b/test/user_all_patch_test.dart @@ -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 _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 _upsert(Map packet) => + packet['upsertUserAll'] as Map; + +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; + // 客户端 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; + expect(userData['charaSlot'], [1001, 0, 0, 0, 0]); + + final playlog = (packet['userPlaylogList'] as List).first + as Map; + 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'); + }); + }); +}