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:
Cookiues committed 2026-09-26 00:37:54 +08:00
1 parent ebd1fdddaa
commit 8dda399f76
24 files changed
+1832 -134

No files matched your search

+12
View File
@@ -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<Widget> responsiveGrid({
+46 -1
View File
@@ -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 = '以下功能会向服务器发送伪造数据,属于高风险操作,请谨慎使用。';
+2 -2
View File
@@ -158,8 +158,8 @@ class _LoginPageState extends State<LoginPage> {
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: [
+68
View File
@@ -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);
+2 -2
View File
@@ -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),
+69 -44
View File
@@ -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<Best50Page> {
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,
),
),
),
),
),
);
},
),
),
],
+3 -3
View File
@@ -13,7 +13,7 @@ class CollectiblesPage extends StatelessWidget {
final int? loginDateTime;
final int? loginId;
final String? lastLoginDate;
final Future<void> Function()? onLogoutRequested;
final Future<void> 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,
+2 -2
View File
@@ -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,
+30 -7
View File
@@ -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<void> Function()? onLogoutRequested;
/// 完成后自动退出登录并返回标题页,由 HomePage 执行结算+退登+pop。
final Future<void> 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<HighRiskFeaturePage> {
loginDateTime: widget.loginDateTime,
loginId: widget.loginId,
lastLoginDate: widget.lastLoginDate,
onLogoutRequested: widget.onLogoutRequested,
onExitToTitle: widget.onExitToTitle,
),
),
);
@@ -114,7 +115,21 @@ class _HighRiskFeaturePageState extends State<HighRiskFeaturePage> {
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<HighRiskFeaturePage> {
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<HighRiskFeaturePage> {
desc: AppStrings.collectiblesFeatureDesc,
onTap: _openCollectibles,
),
const SizedBox(height: 12),
_buildNavCard(
theme,
icon: Icons.workspace_premium_outlined,
title: AppStrings.travelPartnerFeatureTitle,
desc: AppStrings.travelPartnerFeatureDesc,
onTap: _openTravelPartner,
),
],
),
),
+10 -7
View File
@@ -177,7 +177,10 @@ class _HomePageState extends State<HomePage> {
}
_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<HomePage> {
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<HomePage> {
}) {
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<HomePage> {
) {
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),
+253 -32
View File
@@ -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<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 {
final int userId;
final String? cookies;
@@ -37,8 +70,9 @@ class MusicRiskFeatureView extends StatefulWidget {
final int? loginId;
final String? lastLoginDate;
/// 当用户勾选"完成后自动退出登录"时,由 HomePage 负责发送 logout 包。
final Future<void> Function()? onLogoutRequested;
/// 完成后自动退出登录并返回标题页时调用;由 HomePage 提供,
/// 内部会等待结算、发 UserLogoutApi、重置会话并 pop 回主标题。
final Future<void> 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<MusicRiskFeatureView> {
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<String, Map<String, dynamic>>? _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<MusicRiskFeatureView> {
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<String, dynamic> _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<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() {
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<MusicRiskFeatureView> {
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<MusicRiskFeatureView> {
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<MusicRiskFeatureView> {
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<Widget> rows,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 14),
Text(
'$title ($count)',
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 6),
if (count == 0)
Text(
emptyHint,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
)
else
...rows,
],
);
}
Widget _buildPendingRow(
ThemeData theme, {
required String leading,
required String trailing,
required VoidCallback onRemove,
}) {
return Row(
children: [
Expanded(
child: Text(
'$leading $trailing',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
),
),
),
IconButton(
icon: const Icon(Icons.close, size: 18),
tooltip: AppStrings.listRemoveTooltip,
onPressed: _running ? null : onRemove,
color: theme.colorScheme.onSurfaceVariant,
visualDensity: VisualDensity.compact,
),
],
);
}
Widget _buildUnlockCheckbox(
ThemeData theme, {
required bool value,
@@ -595,6 +798,24 @@ class _MusicRiskFeatureViewState extends State<MusicRiskFeatureView> {
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) {
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<MusicRiskFeatureView> {
),
Expanded(
child: Text(
AppStrings.unlockAutoLogout,
AppStrings.autoLogoutAndExit,
style: theme.textTheme.bodyMedium?.copyWith(
color: enabled
? null
+2 -2
View File
@@ -107,8 +107,8 @@ class _SettingsPageState extends State<SettingsPage> {
: null,
body: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: responsiveMaxWidth(context)),
child: responsiveBody(
context,
child: Form(
key: _formKey,
child: Column(
+2 -2
View File
@@ -257,8 +257,8 @@ class _TicketPageState extends State<TicketPage> {
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),
+766
View File
@@ -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)),
],
),
);
}
}
+3 -3
View File
@@ -11,7 +11,7 @@ class UnlockMusicPage extends StatelessWidget {
final int? loginDateTime;
final int? loginId;
final String? lastLoginDate;
final Future<void> Function()? onLogoutRequested;
final Future<void> 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,
+11
View File
@@ -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<dynamic>? ?? [];
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 {
+36 -16
View File
@@ -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<String, dynamic> packet, {
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>;
upsert['userMusicDetailList'] = [musicData];
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];
}
}
}
}