feat: 风险页新增数值修改、一键跑图与万花筒专区
- 修改总 Rating:只写 playerRating 与 userRating.rating,钳 0~99999, 每首歌各自的 ratingList 原样带回 - 添加舞里程(UserDetail.point):完整 int32 区间,支持累加与直接设定, 允许负值即扣减 - 一键跑图:isClear/isComplete 在客户端是从 distance 派生的,单发 flag 会被 冲掉,故把 distance 推过 End 里程针;unlockFlag 语义是反的 - 万花筒专区:发现门与获取钥匙落在 userKaleidxScopeList 同一行的两个布尔位, 钥匙不走 userItemList;上行是整行替换,所以先读回原行再叠加 - 旅行伙伴:修正误导性的示例 ID 1001(合法是三位数,表里没有的 ID 服务器照收 但客户端静默跳过),编组槽位可设定等级,上传后回读复查以区分失败原因 - 补 MIT 许可证
This commit is contained in:
1 parent
329845f228
commit
0808e367f7
15 files changed
+3597
-42
No files matched your search
@@ -6,11 +6,14 @@ import '../config/responsive.dart';
|
||||
import '../config/strings.dart';
|
||||
import '../config/title_server_config.dart';
|
||||
import 'collectibles_page.dart';
|
||||
import 'kaleidx_scope_page.dart';
|
||||
import 'map_traverse_page.dart';
|
||||
import 'number_patch_page.dart';
|
||||
import 'travel_partner_page.dart';
|
||||
import 'unlock_music_page.dart';
|
||||
|
||||
/// 高危功能中转页:导航到 UnlockMusic (解锁歌曲) / 收藏品获取。
|
||||
/// 两个 feature 互相独立,各自的执行流程互不影响。
|
||||
/// 高危功能中转页:导航到解锁歌曲 / 收藏品获取 / 旅行伙伴 / 数值修改。
|
||||
/// 各 feature 互相独立,执行流程互不影响。
|
||||
class HighRiskFeaturePage extends StatefulWidget {
|
||||
final int userId;
|
||||
final String? cookies;
|
||||
@@ -135,6 +138,49 @@ class _HighRiskFeaturePageState extends State<HighRiskFeaturePage> {
|
||||
);
|
||||
}
|
||||
|
||||
void _openNumberPatch(NumberPatchMode mode) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => NumberPatchPage(
|
||||
userId: widget.userId,
|
||||
cookies: widget.cookies,
|
||||
loginDateTime: widget.loginDateTime,
|
||||
loginId: widget.loginId,
|
||||
onExitToTitle: widget.onExitToTitle,
|
||||
mode: mode,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _openMapTraverse() {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => MapTraversePage(
|
||||
userId: widget.userId,
|
||||
cookies: widget.cookies,
|
||||
loginDateTime: widget.loginDateTime,
|
||||
loginId: widget.loginId,
|
||||
onExitToTitle: widget.onExitToTitle,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _openKaleidxScope() {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => KaleidxScopePage(
|
||||
userId: widget.userId,
|
||||
cookies: widget.cookies,
|
||||
loginDateTime: widget.loginDateTime,
|
||||
loginId: widget.loginId,
|
||||
onExitToTitle: widget.onExitToTitle,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
@@ -191,6 +237,38 @@ class _HighRiskFeaturePageState extends State<HighRiskFeaturePage> {
|
||||
desc: AppStrings.travelPartnerFeatureDesc,
|
||||
onTap: _openTravelPartner,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildNavCard(
|
||||
theme,
|
||||
icon: Icons.speed,
|
||||
title: AppStrings.ratingFeatureTitle,
|
||||
desc: AppStrings.ratingFeatureDesc,
|
||||
onTap: () => _openNumberPatch(NumberPatchMode.rating),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildNavCard(
|
||||
theme,
|
||||
icon: Icons.commute,
|
||||
title: AppStrings.maiMileFeatureTitle,
|
||||
desc: AppStrings.maiMileFeatureDesc,
|
||||
onTap: () => _openNumberPatch(NumberPatchMode.maiMile),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildNavCard(
|
||||
theme,
|
||||
icon: Icons.map_outlined,
|
||||
title: AppStrings.mapFeatureTitle,
|
||||
desc: AppStrings.mapFeatureDesc,
|
||||
onTap: _openMapTraverse,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildNavCard(
|
||||
theme,
|
||||
icon: Icons.science_outlined,
|
||||
title: AppStrings.kaleidxFeatureTitle,
|
||||
desc: AppStrings.kaleidxFeatureDesc,
|
||||
onTap: _openKaleidxScope,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,695 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../config/responsive.dart';
|
||||
import '../config/strings.dart';
|
||||
import '../config/title_server_config.dart';
|
||||
import '../models/user_kaleidx_scope.dart';
|
||||
import '../services/title_api_service.dart';
|
||||
import '../services/user_all_payload_builder.dart';
|
||||
|
||||
/// 万花筒专区:发现新的宿命之门 + 获取门的钥匙。
|
||||
///
|
||||
/// 两个动作落在 `upsertUserAll.userKaleidxScopeList` 同一行的两个布尔位上
|
||||
/// (`isGateFound` / `isKeyFound`)。钥匙不走 `userItemList`:
|
||||
/// `ExportUserItems` 从不输出 itemKind 15,下行也没有读它的路径。
|
||||
class KaleidxScopePage extends StatefulWidget {
|
||||
final int userId;
|
||||
final String? cookies;
|
||||
final int? loginDateTime;
|
||||
final int? loginId;
|
||||
final Future<void> Function()? onExitToTitle;
|
||||
|
||||
const KaleidxScopePage({
|
||||
super.key,
|
||||
required this.userId,
|
||||
this.cookies,
|
||||
this.loginDateTime,
|
||||
this.loginId,
|
||||
this.onExitToTitle,
|
||||
});
|
||||
|
||||
@override
|
||||
State<KaleidxScopePage> createState() => _KaleidxScopePageState();
|
||||
}
|
||||
|
||||
enum _Step { idle, fetchData, upload, logout, complete, failed }
|
||||
|
||||
class _KaleidxScopePageState extends State<KaleidxScopePage> {
|
||||
/// 与其它高危功能一致:不写真实成绩,playlog 用同一条占位记录。
|
||||
static const int _placeholderMusicId = 11538;
|
||||
|
||||
final _gateIdController = TextEditingController();
|
||||
final List<int> _pending = [];
|
||||
|
||||
bool _discover = true;
|
||||
bool _giveKey = false;
|
||||
|
||||
Map<String, Map<String, dynamic>> _userAllData = const {};
|
||||
List<UserKaleidxScopeBean> _serverScopes = const [];
|
||||
|
||||
bool _loading = false;
|
||||
bool _running = false;
|
||||
bool _autoLogout = true;
|
||||
_Step _step = _Step.idle;
|
||||
String _stepMessage = '';
|
||||
|
||||
Timer? _cooldownTimer;
|
||||
int _cooldownRemaining = 0;
|
||||
|
||||
bool get _loggedIn => widget.loginDateTime != null;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_syncCooldown();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant KaleidxScopePage oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.loginDateTime != widget.loginDateTime) {
|
||||
_syncCooldown();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_cooldownTimer?.cancel();
|
||||
_gateIdController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _syncCooldown() {
|
||||
_cooldownTimer?.cancel();
|
||||
final loginDateTime = widget.loginDateTime;
|
||||
if (loginDateTime == null) {
|
||||
setState(() => _cooldownRemaining = 0);
|
||||
return;
|
||||
}
|
||||
setState(() => _cooldownRemaining = _computeRemaining(loginDateTime));
|
||||
if (_cooldownRemaining <= 0) return;
|
||||
_cooldownTimer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
if (!mounted) return;
|
||||
final remaining = _computeRemaining(loginDateTime);
|
||||
setState(() => _cooldownRemaining = remaining);
|
||||
if (remaining <= 0) {
|
||||
_cooldownTimer?.cancel();
|
||||
_cooldownTimer = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
int _computeRemaining(int loginDateTime) {
|
||||
final nowSec = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
final remaining =
|
||||
AppStrings.ticketCooldownSeconds - (nowSec - loginDateTime);
|
||||
return remaining < 0 ? 0 : remaining;
|
||||
}
|
||||
|
||||
void _snack(String message) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
|
||||
}
|
||||
|
||||
void _addPending() {
|
||||
final id = int.tryParse(_gateIdController.text.trim()) ?? -1;
|
||||
if (id <= 0) {
|
||||
_snack(AppStrings.kaleidxNeedGateId);
|
||||
return;
|
||||
}
|
||||
if (_pending.contains(id)) {
|
||||
_snack(AppStrings.kaleidxGateIdDuplicated);
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_pending.add(id);
|
||||
_gateIdController.clear();
|
||||
});
|
||||
}
|
||||
|
||||
UserKaleidxScopeBean? _serverRow(int gateId) {
|
||||
for (final row in _serverScopes) {
|
||||
if (row.gateId == gateId) return row;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 整行替换,所以已存在的门必须拿服务器原行做底,只改那两个布尔位。
|
||||
List<UserKaleidxScopeBean> _buildRows() {
|
||||
return [
|
||||
for (final id in _pending)
|
||||
_serverRow(id)?.withActions(discover: _discover, giveKey: _giveKey) ??
|
||||
UserKaleidxScopeBean.discovered(id, giveKey: _giveKey),
|
||||
];
|
||||
}
|
||||
|
||||
Future<void> _fetchData() async {
|
||||
if (!TitleServerConfigHolder().isConfigured) return;
|
||||
setState(() => _loading = true);
|
||||
|
||||
try {
|
||||
final config = TitleServerConfigHolder().config!;
|
||||
final service = TitleApiService(config, cookies: widget.cookies);
|
||||
final results = await Future.wait([
|
||||
service.fetchUserAllData(widget.userId),
|
||||
service.getUserKaleidxScopes(widget.userId),
|
||||
]);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_userAllData = results[0] as Map<String, Map<String, dynamic>>;
|
||||
_serverScopes = results[1] as List<UserKaleidxScopeBean>;
|
||||
_loading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _loading = false);
|
||||
_snack('${AppStrings.loadFailed}: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _run() async {
|
||||
if (!TitleServerConfigHolder().isConfigured) {
|
||||
_snack(AppStrings.ticketNotConfigured);
|
||||
return;
|
||||
}
|
||||
final loginDateTime = widget.loginDateTime;
|
||||
final loginId = widget.loginId;
|
||||
if (loginDateTime == null || loginId == null) {
|
||||
_snack(AppStrings.kaleidxNotLoggedIn);
|
||||
return;
|
||||
}
|
||||
if (_cooldownRemaining > 0) {
|
||||
_snack(AppStrings.kaleidxCooldownNotice(_cooldownRemaining));
|
||||
return;
|
||||
}
|
||||
if (_pending.isEmpty) {
|
||||
_snack(AppStrings.kaleidxNeedGateId);
|
||||
return;
|
||||
}
|
||||
if (!_discover && !_giveKey) {
|
||||
_snack(AppStrings.kaleidxNeedAction);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _running = true);
|
||||
|
||||
try {
|
||||
final config = TitleServerConfigHolder().config!;
|
||||
final service = TitleApiService(config, cookies: widget.cookies);
|
||||
final builder = UserAllPayloadBuilder(config);
|
||||
|
||||
var data = _userAllData;
|
||||
if (data.isEmpty || _serverScopes.isEmpty) {
|
||||
_updateStep(_Step.fetchData);
|
||||
final results = await Future.wait([
|
||||
service.fetchUserAllData(widget.userId),
|
||||
service.getUserKaleidxScopes(widget.userId),
|
||||
]);
|
||||
data = results[0] as Map<String, Map<String, dynamic>>;
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_userAllData = data;
|
||||
_serverScopes = results[1] as List<UserKaleidxScopeBean>;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_updateStep(_Step.upload);
|
||||
final packet = builder.build(
|
||||
userId: widget.userId,
|
||||
loginId: loginId,
|
||||
loginDateTime: loginDateTime,
|
||||
musicData: _placeholderMusicData(),
|
||||
generalUserInfo: data,
|
||||
);
|
||||
|
||||
final rows = _buildRows();
|
||||
// 每一位 '1' 插入 / '0' 更新,判据是服务器有没有同 gateId 的行。
|
||||
final flags = [
|
||||
for (final row in rows) _serverRow(row.gateId) == null ? '1' : '0',
|
||||
].join();
|
||||
builder.applyKaleidxScopePatch(
|
||||
packet,
|
||||
scopes: [for (final row in rows) row.toWireJson()],
|
||||
newFlags: flags,
|
||||
);
|
||||
|
||||
await service.upsertUserAll(packet, widget.userId);
|
||||
_updateStep(_Step.complete, await _verify(service));
|
||||
|
||||
if (_autoLogout && widget.onExitToTitle != null) {
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
if (!mounted) return;
|
||||
_updateStep(_Step.logout, AppStrings.exitingToTitle);
|
||||
await widget.onExitToTitle!();
|
||||
}
|
||||
} on TitleApiException catch (e) {
|
||||
_updateStep(_Step.failed, e.message);
|
||||
} catch (e) {
|
||||
_updateStep(_Step.failed, '$e');
|
||||
} finally {
|
||||
if (mounted) setState(() => _running = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> _verify(TitleApiService service) async {
|
||||
final requested = List<int>.from(_pending);
|
||||
List<UserKaleidxScopeBean> rows;
|
||||
try {
|
||||
rows = await service.getUserKaleidxScopes(widget.userId);
|
||||
} catch (e) {
|
||||
return AppStrings.kaleidxVerifyFailed('$e');
|
||||
}
|
||||
if (mounted) setState(() => _serverScopes = rows);
|
||||
|
||||
final saved = rows.where((r) => requested.contains(r.gateId));
|
||||
final missing = requested
|
||||
.where((id) => !rows.any((r) => r.gateId == id))
|
||||
.toList();
|
||||
if (missing.isNotEmpty) {
|
||||
return AppStrings.kaleidxNotSaved(missing.join(' / '));
|
||||
}
|
||||
return AppStrings.kaleidxVerified(saved.length);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _placeholderMusicData() => {
|
||||
'musicId': _placeholderMusicId,
|
||||
'level': 0,
|
||||
'playCount': 1,
|
||||
'achievement': 0,
|
||||
'comboStatus': 0,
|
||||
'syncStatus': 0,
|
||||
'deluxscoreMax': 0,
|
||||
'scoreRank': 0,
|
||||
'extNum1': 0,
|
||||
};
|
||||
|
||||
void _updateStep(_Step step, [String? message]) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_step = step;
|
||||
_stepMessage = message ?? '';
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text(AppStrings.kaleidxFeatureTitle)),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: responsiveBody(
|
||||
context,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (!_loggedIn)
|
||||
_notice(theme, AppStrings.kaleidxNotLoggedIn, error: true),
|
||||
if (_loggedIn && _cooldownRemaining > 0)
|
||||
_notice(
|
||||
theme,
|
||||
AppStrings.kaleidxCooldownNotice(_cooldownRemaining),
|
||||
),
|
||||
_card(
|
||||
theme,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_titleRow(
|
||||
theme,
|
||||
Icons.science_outlined,
|
||||
AppStrings.kaleidxFeatureTitle,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
AppStrings.kaleidxFeatureDesc,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_inputCard(theme),
|
||||
const SizedBox(height: 12),
|
||||
_fetchCard(theme),
|
||||
const SizedBox(height: 12),
|
||||
_autoLogoutToggle(theme),
|
||||
const SizedBox(height: 12),
|
||||
_runButton(theme),
|
||||
if (_step != _Step.idle) ...[
|
||||
const SizedBox(height: 16),
|
||||
_progressCard(theme),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _notice(ThemeData theme, String text, {bool error = false}) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: error
|
||||
? theme.colorScheme.errorContainer
|
||||
: theme.colorScheme.tertiaryContainer.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Text(
|
||||
text,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: error
|
||||
? theme.colorScheme.onErrorContainer
|
||||
: theme.colorScheme.onTertiaryContainer,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _card(ThemeData theme, {required Widget child}) {
|
||||
return Card(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(
|
||||
color: theme.colorScheme.outline.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
child: Padding(padding: const EdgeInsets.all(20), child: child),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _titleRow(ThemeData theme, IconData icon, String title) {
|
||||
return Row(
|
||||
children: [
|
||||
Icon(icon, size: 18, color: theme.colorScheme.primary),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _checkRow({
|
||||
required String label,
|
||||
required bool value,
|
||||
required bool enabled,
|
||||
required ValueChanged<bool> onChanged,
|
||||
}) {
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
onTap: enabled ? () => onChanged(!value) : null,
|
||||
child: Row(
|
||||
children: [
|
||||
Checkbox(
|
||||
value: value,
|
||||
onChanged: enabled ? (v) => onChanged(v ?? false) : null,
|
||||
),
|
||||
Expanded(child: Text(label)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _inputCard(ThemeData theme) {
|
||||
final enabled = !_running;
|
||||
return _card(
|
||||
theme,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TextField(
|
||||
controller: _gateIdController,
|
||||
enabled: enabled,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: InputDecoration(
|
||||
labelText: AppStrings.kaleidxGateIdLabel,
|
||||
hintText: AppStrings.kaleidxGateIdHint,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
contentPadding: const EdgeInsets.all(14),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: enabled ? _addPending : null,
|
||||
icon: const Icon(Icons.add, size: 18),
|
||||
label: const Text(AppStrings.listAddButton),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
_checkRow(
|
||||
label: AppStrings.kaleidxActionDiscover,
|
||||
value: _discover,
|
||||
enabled: enabled,
|
||||
onChanged: (v) => setState(() => _discover = v),
|
||||
),
|
||||
_checkRow(
|
||||
label: AppStrings.kaleidxActionKey,
|
||||
value: _giveKey,
|
||||
enabled: enabled,
|
||||
onChanged: (v) => setState(() => _giveKey = v),
|
||||
),
|
||||
if (_giveKey) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
AppStrings.kaleidxKeyNeedsGate,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 10),
|
||||
for (final id in _pending)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Text(
|
||||
'#$id ${_stateLabelFor(id)}',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_pending.isEmpty)
|
||||
Text(
|
||||
AppStrings.kaleidxPendingEmpty,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _stateLabelFor(int gateId) {
|
||||
final row = _serverRow(gateId);
|
||||
if (row == null) {
|
||||
return _userAllData.isEmpty
|
||||
? ''
|
||||
: AppStrings.kaleidxGateState(false, false, false);
|
||||
}
|
||||
return AppStrings.kaleidxGateState(
|
||||
row.isGateFound,
|
||||
row.isKeyFound,
|
||||
row.isClear,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _fetchCard(ThemeData theme) {
|
||||
final hasData = _userAllData.isNotEmpty;
|
||||
return _card(
|
||||
theme,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
hasData ? Icons.check_circle : Icons.download,
|
||||
size: 18,
|
||||
color: hasData ? Colors.green : theme.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
hasData
|
||||
? '${AppStrings.kaleidxFetched} (${_serverScopes.length})'
|
||||
: AppStrings.kaleidxNoData,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: hasData
|
||||
? Colors.green
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: 32,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _loading || _running ? null : _fetchData,
|
||||
icon: _loading
|
||||
? const SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.refresh, size: 16),
|
||||
label: Text(
|
||||
_loading
|
||||
? AppStrings.travelPartnerFetching
|
||||
: AppStrings.kaleidxFetchData,
|
||||
style: theme.textTheme.labelSmall,
|
||||
),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _autoLogoutToggle(ThemeData theme) {
|
||||
final enabled = widget.onExitToTitle != null && !_running;
|
||||
return _card(
|
||||
theme,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
onTap: enabled ? () => setState(() => _autoLogout = !_autoLogout) : null,
|
||||
child: Row(
|
||||
children: [
|
||||
Checkbox(
|
||||
value: _autoLogout,
|
||||
onChanged: enabled
|
||||
? (v) => setState(() => _autoLogout = v ?? false)
|
||||
: null,
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
AppStrings.autoLogoutAndExit,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _runButton(ThemeData theme) {
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
onPressed: _running || !_loggedIn ? null : _run,
|
||||
icon: _running
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.lock_open_outlined, size: 20),
|
||||
label: Text(
|
||||
_running ? AppStrings.kaleidxRunning : AppStrings.kaleidxRun,
|
||||
),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _progressCard(ThemeData theme) {
|
||||
final isFailed = _step == _Step.failed;
|
||||
return _card(
|
||||
theme,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_stepRow(theme, _Step.fetchData, AppStrings.kaleidxStepFetch),
|
||||
_stepRow(theme, _Step.upload, AppStrings.kaleidxStepUpload),
|
||||
if (_autoLogout) _stepRow(theme, _Step.logout, AppStrings.stepLogout),
|
||||
if (_stepMessage.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: isFailed
|
||||
? theme.colorScheme.errorContainer
|
||||
: theme.colorScheme.surfaceContainerHighest
|
||||
.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
_stepMessage,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
color: isFailed
|
||||
? theme.colorScheme.onErrorContainer
|
||||
: theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _stepRow(ThemeData theme, _Step step, String label) {
|
||||
final order = [_Step.fetchData, _Step.upload, _Step.logout, _Step.complete];
|
||||
final current = _step == _Step.failed ? _Step.upload : _step;
|
||||
final done = order.indexOf(current) > order.indexOf(step);
|
||||
final active = _step == step;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
done
|
||||
? Icons.check_circle
|
||||
: active
|
||||
? Icons.radio_button_checked
|
||||
: Icons.radio_button_unchecked,
|
||||
size: 18,
|
||||
color: done
|
||||
? Colors.green
|
||||
: active
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(child: Text(label, style: theme.textTheme.bodySmall)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,623 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../config/responsive.dart';
|
||||
import '../config/strings.dart';
|
||||
import '../config/title_server_config.dart';
|
||||
import '../services/title_api_service.dart';
|
||||
import '../services/user_all_payload_builder.dart';
|
||||
|
||||
/// 一键跑图:把选中的区域标记为已完成。
|
||||
///
|
||||
/// 依据反编译客户端:
|
||||
/// - 区域进度是 `upsertUserAll.userMapList`,行结构见 `Net/VO/Mai2/UserMap.cs`
|
||||
/// `{mapId, distance, isLock, isClear, isComplete, unlockFlag}`,主键 mapId。
|
||||
/// - `MapMaster.CreateUserDataMapList`(Util/MapMaster.cs:509-543)用 `distance`
|
||||
/// 与该区域的 ReleaseFlag / End 里程针比较,**重算** isClear / isComplete,
|
||||
/// 没有对应针时甚至强制写回 false。所以只发 flag 会被冲掉,必须把
|
||||
/// `distance` 推过 End 针——这里直接给 `UserMapData.MaxDistance`。
|
||||
/// - `unlockFlag` 语义是反的:`IsFinishedOpening ? 0 : 1`。
|
||||
/// - `isLock` 客户端不回读(`ConvertUserMap` 从不读它)。
|
||||
class MapTraversePage extends StatefulWidget {
|
||||
final int userId;
|
||||
final String? cookies;
|
||||
final int? loginDateTime;
|
||||
final int? loginId;
|
||||
final Future<void> Function()? onExitToTitle;
|
||||
|
||||
const MapTraversePage({
|
||||
super.key,
|
||||
required this.userId,
|
||||
this.cookies,
|
||||
this.loginDateTime,
|
||||
this.loginId,
|
||||
this.onExitToTitle,
|
||||
});
|
||||
|
||||
@override
|
||||
State<MapTraversePage> createState() => _MapTraversePageState();
|
||||
}
|
||||
|
||||
enum _Step { idle, fetchData, upload, logout, complete, failed }
|
||||
|
||||
class _MapTraversePageState extends State<MapTraversePage> {
|
||||
/// 与其它高危功能一致:不写真实成绩,playlog 用同一条占位记录。
|
||||
static const int _placeholderMusicId = 11538;
|
||||
|
||||
final _mapIdController = TextEditingController();
|
||||
final List<int> _pending = [];
|
||||
|
||||
Map<String, Map<String, dynamic>> _userAllData = const {};
|
||||
Set<int>? _serverMapIds;
|
||||
List<Map<String, dynamic>> _serverMaps = const [];
|
||||
|
||||
bool _loading = false;
|
||||
bool _running = false;
|
||||
bool _autoLogout = true;
|
||||
_Step _step = _Step.idle;
|
||||
String _stepMessage = '';
|
||||
|
||||
Timer? _cooldownTimer;
|
||||
int _cooldownRemaining = 0;
|
||||
|
||||
bool get _loggedIn => widget.loginDateTime != null;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_syncCooldown();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant MapTraversePage oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.loginDateTime != widget.loginDateTime) {
|
||||
_syncCooldown();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_cooldownTimer?.cancel();
|
||||
_mapIdController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _syncCooldown() {
|
||||
_cooldownTimer?.cancel();
|
||||
final loginDateTime = widget.loginDateTime;
|
||||
if (loginDateTime == null) {
|
||||
setState(() => _cooldownRemaining = 0);
|
||||
return;
|
||||
}
|
||||
setState(() => _cooldownRemaining = _computeRemaining(loginDateTime));
|
||||
if (_cooldownRemaining <= 0) return;
|
||||
_cooldownTimer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
if (!mounted) return;
|
||||
final remaining = _computeRemaining(loginDateTime);
|
||||
setState(() => _cooldownRemaining = remaining);
|
||||
if (remaining <= 0) {
|
||||
_cooldownTimer?.cancel();
|
||||
_cooldownTimer = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
int _computeRemaining(int loginDateTime) {
|
||||
final nowSec = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
final remaining =
|
||||
AppStrings.ticketCooldownSeconds - (nowSec - loginDateTime);
|
||||
return remaining < 0 ? 0 : remaining;
|
||||
}
|
||||
|
||||
void _snack(String message) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
|
||||
}
|
||||
|
||||
void _addPending() {
|
||||
final id = int.tryParse(_mapIdController.text.trim()) ?? -1;
|
||||
if (id <= 0) {
|
||||
_snack(AppStrings.mapNeedMapId);
|
||||
return;
|
||||
}
|
||||
if (_pending.contains(id)) {
|
||||
_snack(AppStrings.listDuplicate);
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_pending.add(id);
|
||||
_mapIdController.clear();
|
||||
});
|
||||
}
|
||||
|
||||
void _applyServerMaps(List<Map<String, dynamic>> rows) {
|
||||
_serverMaps = rows;
|
||||
_serverMapIds = rows
|
||||
.map((e) => (e['mapId'] as num?)?.toInt() ?? 0)
|
||||
.where((e) => e != 0)
|
||||
.toSet();
|
||||
}
|
||||
|
||||
Future<void> _fetchData() async {
|
||||
if (!TitleServerConfigHolder().isConfigured) return;
|
||||
setState(() => _loading = true);
|
||||
|
||||
try {
|
||||
final config = TitleServerConfigHolder().config!;
|
||||
final service = TitleApiService(config, cookies: widget.cookies);
|
||||
final results = await Future.wait([
|
||||
service.fetchUserAllData(widget.userId),
|
||||
service.getUserMaps(widget.userId),
|
||||
]);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_userAllData = results[0] as Map<String, Map<String, dynamic>>;
|
||||
_applyServerMaps(results[1] as List<Map<String, dynamic>>);
|
||||
_loading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _loading = false);
|
||||
_snack('${AppStrings.loadFailed}: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _run() async {
|
||||
if (!TitleServerConfigHolder().isConfigured) {
|
||||
_snack(AppStrings.ticketNotConfigured);
|
||||
return;
|
||||
}
|
||||
final loginDateTime = widget.loginDateTime;
|
||||
final loginId = widget.loginId;
|
||||
if (loginDateTime == null || loginId == null) {
|
||||
_snack(AppStrings.mapNotLoggedIn);
|
||||
return;
|
||||
}
|
||||
if (_cooldownRemaining > 0) {
|
||||
_snack(AppStrings.mapCooldownNotice(_cooldownRemaining));
|
||||
return;
|
||||
}
|
||||
if (_pending.isEmpty) {
|
||||
_snack(AppStrings.mapNeedMapId);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _running = true);
|
||||
|
||||
try {
|
||||
final config = TitleServerConfigHolder().config!;
|
||||
final service = TitleApiService(config, cookies: widget.cookies);
|
||||
final builder = UserAllPayloadBuilder(config);
|
||||
|
||||
var data = _userAllData;
|
||||
var serverIds = _serverMapIds;
|
||||
if (data.isEmpty || serverIds == null) {
|
||||
_updateStep(_Step.fetchData);
|
||||
final results = await Future.wait([
|
||||
service.fetchUserAllData(widget.userId),
|
||||
service.getUserMaps(widget.userId),
|
||||
]);
|
||||
data = results[0] as Map<String, Map<String, dynamic>>;
|
||||
if (mounted) setState(() => _userAllData = data);
|
||||
_applyServerMaps(results[1] as List<Map<String, dynamic>>);
|
||||
serverIds = _serverMapIds!;
|
||||
}
|
||||
|
||||
_updateStep(_Step.upload);
|
||||
final packet = builder.build(
|
||||
userId: widget.userId,
|
||||
loginId: loginId,
|
||||
loginDateTime: loginDateTime,
|
||||
musicData: _placeholderMusicData(),
|
||||
generalUserInfo: data,
|
||||
);
|
||||
|
||||
// isNewMapList 与 userMapList 一一对齐,每位 '1' 新增 / '0' 更新,
|
||||
// 判据是服务器是否已有同 mapId 的行(VOExtensions.BuildListData)。
|
||||
final maps = [
|
||||
for (final id in _pending) UserAllPayloadBuilder.completedMap(id),
|
||||
];
|
||||
final flags = [
|
||||
for (final id in _pending) serverIds.contains(id) ? '0' : '1',
|
||||
].join();
|
||||
builder.applyMapPatch(packet, maps: maps, newFlags: flags);
|
||||
|
||||
await service.upsertUserAll(packet, widget.userId);
|
||||
_updateStep(_Step.complete, await _verify(service));
|
||||
|
||||
if (_autoLogout && widget.onExitToTitle != null) {
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
if (!mounted) return;
|
||||
_updateStep(_Step.logout, AppStrings.exitingToTitle);
|
||||
await widget.onExitToTitle!();
|
||||
}
|
||||
} on TitleApiException catch (e) {
|
||||
_updateStep(_Step.failed, e.message);
|
||||
} catch (e) {
|
||||
_updateStep(_Step.failed, '$e');
|
||||
} finally {
|
||||
if (mounted) setState(() => _running = false);
|
||||
}
|
||||
}
|
||||
|
||||
/// 回读区分「服务器没写入 userMapList」和「写入了但游戏里仍显示未完成」。
|
||||
Future<String> _verify(TitleApiService service) async {
|
||||
final requested = List<int>.from(_pending);
|
||||
List<Map<String, dynamic>> rows;
|
||||
try {
|
||||
rows = await service.getUserMaps(widget.userId);
|
||||
} catch (e) {
|
||||
return AppStrings.mapVerifyFailed('$e');
|
||||
}
|
||||
if (mounted) setState(() => _applyServerMaps(rows));
|
||||
|
||||
final saved = _serverMapIds ?? <int>{};
|
||||
final missing = requested.where((id) => !saved.contains(id)).toList();
|
||||
if (missing.isNotEmpty) {
|
||||
return AppStrings.mapNotSaved(missing.join(' / '));
|
||||
}
|
||||
return AppStrings.mapVerified(requested.length);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _placeholderMusicData() => {
|
||||
'musicId': _placeholderMusicId,
|
||||
'level': 0,
|
||||
'playCount': 1,
|
||||
'achievement': 0,
|
||||
'comboStatus': 0,
|
||||
'syncStatus': 0,
|
||||
'deluxscoreMax': 0,
|
||||
'scoreRank': 0,
|
||||
'extNum1': 0,
|
||||
};
|
||||
|
||||
void _updateStep(_Step step, [String? message]) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_step = step;
|
||||
_stepMessage = message ?? '';
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text(AppStrings.mapFeatureTitle)),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: responsiveBody(
|
||||
context,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (!_loggedIn)
|
||||
_notice(theme, AppStrings.mapNotLoggedIn, error: true),
|
||||
if (_loggedIn && _cooldownRemaining > 0)
|
||||
_notice(theme, AppStrings.mapCooldownNotice(_cooldownRemaining)),
|
||||
_card(
|
||||
theme,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_titleRow(
|
||||
theme,
|
||||
Icons.map_outlined,
|
||||
AppStrings.mapFeatureTitle,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
AppStrings.mapFeatureDesc,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
AppStrings.mapCollectiblesNotice,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.error,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_inputCard(theme),
|
||||
const SizedBox(height: 12),
|
||||
_fetchCard(theme),
|
||||
const SizedBox(height: 12),
|
||||
_autoLogoutToggle(theme),
|
||||
const SizedBox(height: 12),
|
||||
_runButton(theme),
|
||||
if (_step != _Step.idle) ...[
|
||||
const SizedBox(height: 16),
|
||||
_progressCard(theme),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _notice(ThemeData theme, String text, {bool error = false}) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: error
|
||||
? theme.colorScheme.errorContainer
|
||||
: theme.colorScheme.tertiaryContainer.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Text(
|
||||
text,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: error
|
||||
? theme.colorScheme.onErrorContainer
|
||||
: theme.colorScheme.onTertiaryContainer,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _card(ThemeData theme, {required Widget child}) {
|
||||
return Card(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(
|
||||
color: theme.colorScheme.outline.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
child: Padding(padding: const EdgeInsets.all(20), child: child),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _titleRow(ThemeData theme, IconData icon, String title) {
|
||||
return Row(
|
||||
children: [
|
||||
Icon(icon, size: 18, color: theme.colorScheme.primary),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _inputCard(ThemeData theme) {
|
||||
final enabled = !_running;
|
||||
return _card(
|
||||
theme,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TextField(
|
||||
controller: _mapIdController,
|
||||
enabled: enabled,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: InputDecoration(
|
||||
labelText: AppStrings.mapIdLabel,
|
||||
hintText: AppStrings.mapIdHint,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
contentPadding: const EdgeInsets.all(14),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: enabled ? _addPending : null,
|
||||
icon: const Icon(Icons.add, size: 18),
|
||||
label: const Text(AppStrings.listAddButton),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
_pending.isEmpty
|
||||
? AppStrings.mapPendingEmpty
|
||||
: _pending.map((e) => '#$e').join(' '),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
color: _pending.isEmpty
|
||||
? theme.colorScheme.onSurfaceVariant
|
||||
: theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _fetchCard(ThemeData theme) {
|
||||
final hasData = _serverMapIds != null;
|
||||
return _card(
|
||||
theme,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
hasData ? Icons.check_circle : Icons.download,
|
||||
size: 18,
|
||||
color: hasData ? Colors.green : theme.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
hasData
|
||||
? '${AppStrings.mapFetched} (${_serverMaps.length})'
|
||||
: AppStrings.mapNoData,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: hasData
|
||||
? Colors.green
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: 32,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _loading || _running ? null : _fetchData,
|
||||
icon: _loading
|
||||
? const SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.refresh, size: 16),
|
||||
label: Text(
|
||||
_loading
|
||||
? AppStrings.travelPartnerFetching
|
||||
: AppStrings.mapFetchData,
|
||||
style: theme.textTheme.labelSmall,
|
||||
),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _autoLogoutToggle(ThemeData theme) {
|
||||
final enabled = widget.onExitToTitle != null && !_running;
|
||||
return _card(
|
||||
theme,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
onTap: enabled ? () => setState(() => _autoLogout = !_autoLogout) : null,
|
||||
child: Row(
|
||||
children: [
|
||||
Checkbox(
|
||||
value: _autoLogout,
|
||||
onChanged: enabled
|
||||
? (v) => setState(() => _autoLogout = v ?? false)
|
||||
: null,
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
AppStrings.autoLogoutAndExit,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _runButton(ThemeData theme) {
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
onPressed: _running || !_loggedIn ? null : _run,
|
||||
icon: _running
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.flag_outlined, size: 20),
|
||||
label: Text(
|
||||
_running ? AppStrings.mapRunning : AppStrings.mapRun,
|
||||
),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _progressCard(ThemeData theme) {
|
||||
final isFailed = _step == _Step.failed;
|
||||
return _card(
|
||||
theme,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_stepRow(theme, _Step.fetchData, AppStrings.mapStepFetch),
|
||||
_stepRow(theme, _Step.upload, AppStrings.mapStepUpload),
|
||||
if (_autoLogout) _stepRow(theme, _Step.logout, AppStrings.stepLogout),
|
||||
if (_stepMessage.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: isFailed
|
||||
? theme.colorScheme.errorContainer
|
||||
: theme.colorScheme.surfaceContainerHighest
|
||||
.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
_stepMessage,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
color: isFailed
|
||||
? theme.colorScheme.onErrorContainer
|
||||
: theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _stepRow(ThemeData theme, _Step step, String label) {
|
||||
final order = [_Step.fetchData, _Step.upload, _Step.logout, _Step.complete];
|
||||
final current = _step == _Step.failed ? _Step.upload : _step;
|
||||
final done = order.indexOf(current) > order.indexOf(step);
|
||||
final active = _step == step;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
done
|
||||
? Icons.check_circle
|
||||
: active
|
||||
? Icons.radio_button_checked
|
||||
: Icons.radio_button_unchecked,
|
||||
size: 18,
|
||||
color: done
|
||||
? Colors.green
|
||||
: active
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(child: Text(label, style: theme.textTheme.bodySmall)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,719 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../config/responsive.dart';
|
||||
import '../config/strings.dart';
|
||||
import '../config/title_server_config.dart';
|
||||
import '../services/title_api_service.dart';
|
||||
import '../services/user_all_payload_builder.dart';
|
||||
|
||||
/// 数值修改:Rating 与舞里程。两者的流程一模一样(拉数据 → 改一个数 →
|
||||
/// UpsertUserAll),差别只在改哪个字段,所以合成一个带模式的视图。
|
||||
enum NumberPatchMode {
|
||||
/// 总 Rating:`userData.playerRating` + `userRating.rating`
|
||||
rating,
|
||||
|
||||
/// 舞里程:`userData.point`(余额),可带上 `totalPoint`(累计)
|
||||
maiMile,
|
||||
}
|
||||
|
||||
class NumberPatchPage extends StatelessWidget {
|
||||
final int userId;
|
||||
final String? cookies;
|
||||
final int? loginDateTime;
|
||||
final int? loginId;
|
||||
final Future<void> Function()? onExitToTitle;
|
||||
final NumberPatchMode mode;
|
||||
|
||||
const NumberPatchPage({
|
||||
super.key,
|
||||
required this.userId,
|
||||
this.cookies,
|
||||
this.loginDateTime,
|
||||
this.loginId,
|
||||
this.onExitToTitle,
|
||||
required this.mode,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isRating = mode == NumberPatchMode.rating;
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
isRating
|
||||
? AppStrings.ratingFeatureTitle
|
||||
: AppStrings.maiMileFeatureTitle,
|
||||
),
|
||||
),
|
||||
body: NumberPatchView(
|
||||
userId: userId,
|
||||
cookies: cookies,
|
||||
loginDateTime: loginDateTime,
|
||||
loginId: loginId,
|
||||
onExitToTitle: onExitToTitle,
|
||||
mode: mode,
|
||||
featureTitle: isRating
|
||||
? AppStrings.ratingFeatureTitle
|
||||
: AppStrings.maiMileFeatureTitle,
|
||||
featureDesc: isRating
|
||||
? AppStrings.ratingFeatureDesc
|
||||
: AppStrings.maiMileFeatureDesc,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum _Step { idle, fetchData, upload, logout, complete, failed }
|
||||
|
||||
class NumberPatchView extends StatefulWidget {
|
||||
final int userId;
|
||||
final String? cookies;
|
||||
|
||||
/// 来自 HomePage 启动时的 UserLoginApi,为 `null` 表示没有有效登录态。
|
||||
final int? loginDateTime;
|
||||
final int? loginId;
|
||||
final Future<void> Function()? onExitToTitle;
|
||||
|
||||
final NumberPatchMode mode;
|
||||
final String featureTitle;
|
||||
final String featureDesc;
|
||||
|
||||
const NumberPatchView({
|
||||
super.key,
|
||||
required this.userId,
|
||||
this.cookies,
|
||||
this.loginDateTime,
|
||||
this.loginId,
|
||||
this.onExitToTitle,
|
||||
required this.mode,
|
||||
required this.featureTitle,
|
||||
required this.featureDesc,
|
||||
});
|
||||
|
||||
@override
|
||||
State<NumberPatchView> createState() => _NumberPatchViewState();
|
||||
}
|
||||
|
||||
class _NumberPatchViewState extends State<NumberPatchView> {
|
||||
/// 与解锁/收藏品/旅行伙伴一致:这些操作不写真实成绩,
|
||||
/// playlog 沿用同一条占位记录,Rating 修改需要它来带 before/after。
|
||||
static const int _placeholderMusicId = 11538;
|
||||
|
||||
final _valueController = TextEditingController();
|
||||
|
||||
Map<String, Map<String, dynamic>> _userAllData = const {};
|
||||
bool _maiMileAdd = true;
|
||||
|
||||
bool _loading = false;
|
||||
bool _running = false;
|
||||
bool _autoLogout = true;
|
||||
_Step _step = _Step.idle;
|
||||
String _stepMessage = '';
|
||||
|
||||
Timer? _cooldownTimer;
|
||||
int _cooldownRemaining = 0;
|
||||
|
||||
bool get _isRating => widget.mode == NumberPatchMode.rating;
|
||||
bool get _loggedIn => widget.loginDateTime != null;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_syncCooldown();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant NumberPatchView oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.loginDateTime != widget.loginDateTime) {
|
||||
_syncCooldown();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_cooldownTimer?.cancel();
|
||||
_valueController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _syncCooldown() {
|
||||
_cooldownTimer?.cancel();
|
||||
final loginDateTime = widget.loginDateTime;
|
||||
if (loginDateTime == null) {
|
||||
setState(() => _cooldownRemaining = 0);
|
||||
return;
|
||||
}
|
||||
setState(() => _cooldownRemaining = _computeRemaining(loginDateTime));
|
||||
if (_cooldownRemaining <= 0) return;
|
||||
_cooldownTimer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
if (!mounted) return;
|
||||
final remaining = _computeRemaining(loginDateTime);
|
||||
setState(() => _cooldownRemaining = remaining);
|
||||
if (remaining <= 0) {
|
||||
_cooldownTimer?.cancel();
|
||||
_cooldownTimer = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
int _computeRemaining(int loginDateTime) {
|
||||
final nowSec = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
final remaining =
|
||||
AppStrings.ticketCooldownSeconds - (nowSec - loginDateTime);
|
||||
return remaining < 0 ? 0 : remaining;
|
||||
}
|
||||
|
||||
void _snack(String message) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(message)),
|
||||
);
|
||||
}
|
||||
|
||||
static int _toInt(dynamic v) {
|
||||
if (v is int) return v;
|
||||
if (v is num) return v.toInt();
|
||||
if (v is String) return int.tryParse(v) ?? 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// 从已拉取的 GetUserDataApi 里读当前值,供「累加」和界面上的当前值展示。
|
||||
int _currentOf(Map<String, Map<String, dynamic>> data, String field) {
|
||||
final userData = data['GetUserDataApi']?['userData'];
|
||||
if (userData is! Map) return 0;
|
||||
return _toInt(userData[field]);
|
||||
}
|
||||
|
||||
int get _currentRating => _currentOf(_userAllData, 'playerRating');
|
||||
int get _currentPoint => _currentOf(_userAllData, 'point');
|
||||
|
||||
/// Rating 的区间是游戏内的 0~99999;舞里程是整个 int32,允许负值
|
||||
/// (累加模式填负数就是扣里程)。
|
||||
int get _min => _isRating ? 0 : UserAllPayloadBuilder.minValue;
|
||||
int get _max => _isRating
|
||||
? UserAllPayloadBuilder.maxRating
|
||||
: UserAllPayloadBuilder.maxValue;
|
||||
|
||||
int _clamp(int v) => v < _min ? _min : (v > _max ? _max : v);
|
||||
|
||||
/// 界面上「将写入」的预览值。未拉取数据时按 0 起算,仅用于提示。
|
||||
int get _previewTarget {
|
||||
final input = int.tryParse(_valueController.text.trim());
|
||||
if (input == null) return 0;
|
||||
if (_isRating) return _clamp(input);
|
||||
return _maiMileAdd ? _clamp(_currentPoint + input) : _clamp(input);
|
||||
}
|
||||
|
||||
String? _checkInput() {
|
||||
final text = _valueController.text.trim();
|
||||
final input = int.tryParse(text);
|
||||
if (input == null) {
|
||||
return _isRating ? AppStrings.ratingNeedValue : AppStrings.maiMileNeedValue;
|
||||
}
|
||||
if (input < _min || input > _max) {
|
||||
return AppStrings.numberPatchOutOfRange(_min, _max);
|
||||
}
|
||||
if (_isRating) return null;
|
||||
if (_maiMileAdd && input == 0) return AppStrings.maiMileNeedValue;
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> _fetchData() async {
|
||||
if (!TitleServerConfigHolder().isConfigured) return;
|
||||
setState(() => _loading = true);
|
||||
|
||||
try {
|
||||
final config = TitleServerConfigHolder().config!;
|
||||
final service = TitleApiService(config, cookies: widget.cookies);
|
||||
final data = await service.fetchUserAllData(widget.userId);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_userAllData = data;
|
||||
_loading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _loading = false);
|
||||
_snack('${AppStrings.loadFailed}: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _run() async {
|
||||
if (!TitleServerConfigHolder().isConfigured) {
|
||||
_snack(AppStrings.ticketNotConfigured);
|
||||
return;
|
||||
}
|
||||
final loginDateTime = widget.loginDateTime;
|
||||
final loginId = widget.loginId;
|
||||
if (loginDateTime == null || loginId == null) {
|
||||
_snack(AppStrings.numberPatchNotLoggedIn);
|
||||
return;
|
||||
}
|
||||
if (_cooldownRemaining > 0) {
|
||||
_snack(AppStrings.numberPatchCooldownNotice(_cooldownRemaining));
|
||||
return;
|
||||
}
|
||||
final inputError = _checkInput();
|
||||
if (inputError != null) {
|
||||
_snack(inputError);
|
||||
return;
|
||||
}
|
||||
final input = int.parse(_valueController.text.trim());
|
||||
|
||||
setState(() => _running = true);
|
||||
|
||||
try {
|
||||
final config = TitleServerConfigHolder().config!;
|
||||
final service = TitleApiService(config, cookies: widget.cookies);
|
||||
final builder = UserAllPayloadBuilder(config);
|
||||
|
||||
var data = _userAllData;
|
||||
if (data.isEmpty) {
|
||||
_updateStep(_Step.fetchData);
|
||||
data = await service.fetchUserAllData(widget.userId);
|
||||
if (mounted) setState(() => _userAllData = data);
|
||||
}
|
||||
|
||||
_updateStep(_Step.upload);
|
||||
final packet = builder.build(
|
||||
userId: widget.userId,
|
||||
loginId: loginId,
|
||||
loginDateTime: loginDateTime,
|
||||
musicData: _placeholderMusicData(),
|
||||
generalUserInfo: data,
|
||||
);
|
||||
|
||||
final String resultMessage;
|
||||
if (_isRating) {
|
||||
builder.applyRatingPatch(packet, rating: input);
|
||||
resultMessage =
|
||||
'${AppStrings.ratingSuccess} → ${_clamp(input)}';
|
||||
} else {
|
||||
final current = _currentOf(data, 'point');
|
||||
final total = _currentOf(data, 'totalPoint');
|
||||
final point = _maiMileAdd ? _clamp(current + input) : _clamp(input);
|
||||
builder.applyMaiMilePatch(
|
||||
packet,
|
||||
point: point,
|
||||
// 累加才动累计值;「直接设定」只覆写余额,累计是历史事实。
|
||||
totalPoint: _maiMileAdd ? _clamp(total + input) : null,
|
||||
);
|
||||
resultMessage = '${AppStrings.maiMileSuccess} → $point';
|
||||
}
|
||||
|
||||
await service.upsertUserAll(packet, widget.userId);
|
||||
_updateStep(_Step.complete, resultMessage);
|
||||
|
||||
if (_autoLogout && widget.onExitToTitle != null) {
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
if (!mounted) return;
|
||||
_updateStep(_Step.logout, AppStrings.exitingToTitle);
|
||||
await widget.onExitToTitle!();
|
||||
}
|
||||
} on TitleApiException catch (e) {
|
||||
_updateStep(_Step.failed, e.message);
|
||||
} catch (e) {
|
||||
_updateStep(_Step.failed, '$e');
|
||||
} finally {
|
||||
if (mounted) setState(() => _running = false);
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> _placeholderMusicData() => {
|
||||
'musicId': _placeholderMusicId,
|
||||
'level': 0,
|
||||
'playCount': 1,
|
||||
'achievement': 0,
|
||||
'comboStatus': 0,
|
||||
'syncStatus': 0,
|
||||
'deluxscoreMax': 0,
|
||||
'scoreRank': 0,
|
||||
'extNum1': 0,
|
||||
};
|
||||
|
||||
void _updateStep(_Step step, [String? message]) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_step = step;
|
||||
_stepMessage = message ?? '';
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: responsiveBody(
|
||||
context,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (!_loggedIn)
|
||||
_notice(theme, AppStrings.numberPatchNotLoggedIn, error: true),
|
||||
if (_loggedIn && _cooldownRemaining > 0)
|
||||
_notice(
|
||||
theme,
|
||||
AppStrings.numberPatchCooldownNotice(_cooldownRemaining),
|
||||
),
|
||||
_descCard(theme),
|
||||
const SizedBox(height: 12),
|
||||
_inputCard(theme),
|
||||
const SizedBox(height: 12),
|
||||
_fetchCard(theme),
|
||||
const SizedBox(height: 12),
|
||||
_autoLogoutToggle(theme),
|
||||
const SizedBox(height: 12),
|
||||
_runButton(theme),
|
||||
if (_step != _Step.idle) ...[
|
||||
const SizedBox(height: 16),
|
||||
_progressCard(theme),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _notice(ThemeData theme, String text, {bool error = false}) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: error
|
||||
? theme.colorScheme.errorContainer
|
||||
: theme.colorScheme.tertiaryContainer.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Text(
|
||||
text,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: error
|
||||
? theme.colorScheme.onErrorContainer
|
||||
: theme.colorScheme.onTertiaryContainer,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _card(ThemeData theme, {required Widget child}) {
|
||||
return Card(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(
|
||||
color: theme.colorScheme.outline.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
child: Padding(padding: const EdgeInsets.all(20), child: child),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _descCard(ThemeData theme) {
|
||||
return _card(
|
||||
theme,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
_isRating ? Icons.speed : Icons.commute,
|
||||
size: 18,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.featureTitle,
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
widget.featureDesc,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _inputCard(ThemeData theme) {
|
||||
final enabled = !_running;
|
||||
final hasInput = int.tryParse(_valueController.text.trim()) != null;
|
||||
|
||||
return _card(
|
||||
theme,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextField(
|
||||
controller: _valueController,
|
||||
enabled: enabled,
|
||||
keyboardType: TextInputType.number,
|
||||
onChanged: (_) => setState(() {}),
|
||||
decoration: InputDecoration(
|
||||
labelText: _isRating
|
||||
? AppStrings.ratingValueLabel
|
||||
: AppStrings.maiMileValueLabel,
|
||||
hintText: _isRating
|
||||
? AppStrings.ratingValueHint
|
||||
: AppStrings.maiMileValueHint,
|
||||
helperText: AppStrings.numberPatchRangeHint(_min, _max),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
contentPadding: const EdgeInsets.all(14),
|
||||
),
|
||||
),
|
||||
if (!_isRating) ...[
|
||||
const SizedBox(height: 12),
|
||||
DropdownButtonFormField<int>(
|
||||
initialValue: _maiMileAdd ? 1 : 0,
|
||||
decoration: InputDecoration(
|
||||
labelText: AppStrings.maiMileModeLabel,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
contentPadding: const EdgeInsets.all(14),
|
||||
),
|
||||
items: [
|
||||
DropdownMenuItem(
|
||||
value: 1,
|
||||
child: Text(
|
||||
AppStrings.maiMileModeAdd,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 0,
|
||||
child: Text(
|
||||
AppStrings.maiMileModeSet,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
],
|
||||
onChanged: enabled
|
||||
? (v) => setState(() => _maiMileAdd = (v ?? 1) == 1)
|
||||
: null,
|
||||
),
|
||||
],
|
||||
// 累加要基于服务器上的当前余额,没拉取数据前预览不了,就别猜一个 0。
|
||||
if (hasInput && (_isRating || _userAllData.isNotEmpty)) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
_previewLine,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String get _previewLine {
|
||||
final label = _isRating
|
||||
? AppStrings.ratingCurrentValue
|
||||
: AppStrings.maiMileCurrentBalance;
|
||||
final current = _isRating ? _currentRating : _currentPoint;
|
||||
return AppStrings.numberPatchPreview(label, current, _previewTarget);
|
||||
}
|
||||
|
||||
Widget _fetchCard(ThemeData theme) {
|
||||
final hasData = _userAllData.isNotEmpty;
|
||||
final current = _isRating ? _currentRating : _currentPoint;
|
||||
final label = _isRating
|
||||
? AppStrings.ratingCurrentValue
|
||||
: AppStrings.maiMileCurrentBalance;
|
||||
|
||||
return _card(
|
||||
theme,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
hasData ? Icons.check_circle : Icons.download,
|
||||
size: 18,
|
||||
color: hasData ? Colors.green : theme.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
hasData
|
||||
? '$label: $current'
|
||||
: AppStrings.unlockNoData,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
color: hasData
|
||||
? Colors.green
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: 32,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _loading || _running ? null : _fetchData,
|
||||
icon: _loading
|
||||
? const SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.refresh, size: 16),
|
||||
label: Text(
|
||||
_loading
|
||||
? AppStrings.unlockFetching
|
||||
: hasData
|
||||
? AppStrings.unlockRefetch
|
||||
: AppStrings.unlockFetchData,
|
||||
style: theme.textTheme.labelSmall,
|
||||
),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _autoLogoutToggle(ThemeData theme) {
|
||||
final enabled = widget.onExitToTitle != null && !_running;
|
||||
return _card(
|
||||
theme,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
onTap: enabled ? () => setState(() => _autoLogout = !_autoLogout) : null,
|
||||
child: Row(
|
||||
children: [
|
||||
Checkbox(
|
||||
value: _autoLogout,
|
||||
onChanged: enabled
|
||||
? (v) => setState(() => _autoLogout = v ?? false)
|
||||
: null,
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
AppStrings.autoLogoutAndExit,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _runButton(ThemeData theme) {
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
onPressed: _running || !_loggedIn ? null : _run,
|
||||
icon: _running
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.send, size: 20),
|
||||
label: Text(
|
||||
_running ? AppStrings.unlockRunning : AppStrings.numberPatchRun,
|
||||
),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _progressCard(ThemeData theme) {
|
||||
final isFailed = _step == _Step.failed;
|
||||
return _card(
|
||||
theme,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_stepRow(theme, _Step.fetchData, AppStrings.unlockStepFetch),
|
||||
_stepRow(theme, _Step.upload, AppStrings.numberPatchStepUpload),
|
||||
if (_autoLogout)
|
||||
_stepRow(theme, _Step.logout, AppStrings.stepLogout),
|
||||
if (_stepMessage.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: isFailed
|
||||
? theme.colorScheme.errorContainer
|
||||
: theme.colorScheme.surfaceContainerHighest
|
||||
.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
_stepMessage,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
color: isFailed
|
||||
? theme.colorScheme.onErrorContainer
|
||||
: theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _stepRow(ThemeData theme, _Step step, String label) {
|
||||
final order = [_Step.fetchData, _Step.upload, _Step.logout, _Step.complete];
|
||||
final current = _step == _Step.failed ? _Step.upload : _step;
|
||||
final done = order.indexOf(current) > order.indexOf(step);
|
||||
final active = _step == step;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
done
|
||||
? Icons.check_circle
|
||||
: active
|
||||
? Icons.radio_button_checked
|
||||
: Icons.radio_button_unchecked,
|
||||
size: 18,
|
||||
color: done
|
||||
? Colors.green
|
||||
: active
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(child: Text(label, style: theme.textTheme.bodySmall)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,12 @@ class _TravelPartnerPageState extends State<TravelPartnerPage> {
|
||||
|
||||
final _grantIdController = TextEditingController();
|
||||
|
||||
/// 每个出战槽位一个等级输入框,留空表示不改该角色的等级。
|
||||
final _slotLevelControllers = List<TextEditingController>.generate(
|
||||
5,
|
||||
(_) => TextEditingController(),
|
||||
);
|
||||
|
||||
List<UserCharacterBean> _owned = const [];
|
||||
Map<String, Map<String, dynamic>> _userAllData = const {};
|
||||
final List<int> _pendingGrant = [];
|
||||
@@ -82,6 +88,9 @@ class _TravelPartnerPageState extends State<TravelPartnerPage> {
|
||||
void dispose() {
|
||||
_cooldownTimer?.cancel();
|
||||
_grantIdController.dispose();
|
||||
for (final c in _slotLevelControllers) {
|
||||
c.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -192,9 +201,28 @@ class _TravelPartnerPageState extends State<TravelPartnerPage> {
|
||||
}
|
||||
setState(() {
|
||||
_slots = normalizeCharaSlot([for (var i = 0; i < 5; i++) leader]);
|
||||
// 五个槽位这时都是同一个角色,等级跟着一起复制才对得上。
|
||||
final leaderLevel = _slotLevelControllers[0].text;
|
||||
for (var i = 1; i < 5; i++) {
|
||||
_slotLevelControllers[i].text = leaderLevel;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 槽位上填了的等级 -> characterId -> level。同一角色占多个槽位时取最高的,
|
||||
/// 否则两个槽位填了不同等级就没有确定答案。
|
||||
Map<int, int> _slotLevelsById() {
|
||||
final result = <int, int>{};
|
||||
for (var i = 0; i < 5; i++) {
|
||||
final id = _slots[i];
|
||||
final level = int.tryParse(_slotLevelControllers[i].text.trim());
|
||||
if (id == 0 || level == null) continue;
|
||||
final existing = result[id];
|
||||
if (existing == null || level > existing) result[id] = level;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
String? _validate() {
|
||||
if (_pendingGrant.isEmpty && _userAllData.isEmpty) {
|
||||
return AppStrings.travelPartnerNoData;
|
||||
@@ -205,6 +233,11 @@ class _TravelPartnerPageState extends State<TravelPartnerPage> {
|
||||
return AppStrings.travelPartnerNotOwnedHint;
|
||||
}
|
||||
}
|
||||
const min = UserAllPayloadBuilder.minCharacterLevel;
|
||||
const max = UserAllPayloadBuilder.maxCharacterLevel;
|
||||
for (final level in _slotLevelsById().values) {
|
||||
if (level < min || level > max) return AppStrings.travelPartnerLevelInvalid;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -252,21 +285,51 @@ class _TravelPartnerPageState extends State<TravelPartnerPage> {
|
||||
generalUserInfo: data,
|
||||
);
|
||||
|
||||
if (_pendingGrant.isNotEmpty) {
|
||||
// 新角色给最小可用值;已拥有的不重发,避免把 level/awakening 打回。
|
||||
final levels = _slotLevelsById();
|
||||
final grantRows = [
|
||||
for (final id in _pendingGrant)
|
||||
{
|
||||
// 新角色默认最小可用值;槽位上填了等级就顺手带上,省一次操作。
|
||||
'characterId': id,
|
||||
'level': UserAllPayloadBuilder.clampCharacterLevel(
|
||||
levels[id] ?? 1,
|
||||
),
|
||||
'awakening': 0,
|
||||
'useCount': 0,
|
||||
},
|
||||
];
|
||||
// 已拥有且槽位要求改等级的,发 isNew='0' 的更新行。必须带上原有
|
||||
// useCount / awakening,否则整行被覆盖会把使用次数清零。
|
||||
final ownedById = {for (final c in _owned) c.characterId: c};
|
||||
final levelRows = <Map<String, dynamic>>[];
|
||||
for (final entry in levels.entries) {
|
||||
if (_pendingGrant.contains(entry.key)) continue;
|
||||
final owned = ownedById[entry.key];
|
||||
if (owned == null) continue;
|
||||
final level = UserAllPayloadBuilder.clampCharacterLevel(entry.value);
|
||||
if (level == owned.level) continue;
|
||||
levelRows.add(owned.copyWith(level: level).toWireJson());
|
||||
}
|
||||
if (grantRows.isNotEmpty || levelRows.isNotEmpty) {
|
||||
builder.applyCharacterListPatch(
|
||||
packet,
|
||||
characters: [
|
||||
for (final id in _pendingGrant)
|
||||
{'characterId': id, 'level': 1, 'awakening': 0, 'useCount': 0},
|
||||
],
|
||||
characters: [...grantRows, ...levelRows],
|
||||
newFlags: '${'1' * grantRows.length}${'0' * levelRows.length}',
|
||||
);
|
||||
}
|
||||
builder.applyCharaSlotPatch(packet, charaSlot: _slots);
|
||||
|
||||
await service.upsertUserAll(packet, widget.userId);
|
||||
|
||||
_updateStep(_Step.complete, AppStrings.travelPartnerSuccess);
|
||||
// 两种失败在返回值上完全一样(returnCode 都是 1),只能靠回读区分:
|
||||
// 服务器没写入 userCharacterList,还是写入了但 ID 不在机台 Chara 表里
|
||||
// 被客户端 CharacterSelectProces 静默跳过。
|
||||
_updateStep(
|
||||
_Step.complete,
|
||||
_pendingGrant.isEmpty
|
||||
? AppStrings.travelPartnerSuccess
|
||||
: await _verifyGranted(service),
|
||||
);
|
||||
|
||||
if (_autoLogout && widget.onExitToTitle != null) {
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
@@ -283,6 +346,22 @@ class _TravelPartnerPageState extends State<TravelPartnerPage> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> _verifyGranted(TitleApiService service) async {
|
||||
final requested = List<int>.from(_pendingGrant);
|
||||
List<UserCharacterBean> owned;
|
||||
try {
|
||||
owned = await service.getUserCharacters(widget.userId);
|
||||
} catch (e) {
|
||||
return AppStrings.travelPartnerVerifyFailed('$e');
|
||||
}
|
||||
if (mounted) setState(() => _owned = owned);
|
||||
|
||||
final saved = owned.map((c) => c.characterId).toSet();
|
||||
final missing = requested.where((id) => !saved.contains(id)).toList();
|
||||
if (missing.isEmpty) return AppStrings.travelPartnerVerified(requested.length);
|
||||
return AppStrings.travelPartnerNotSaved(missing.join(' / '));
|
||||
}
|
||||
|
||||
Map<String, dynamic> _placeholderMusicData() => {
|
||||
'musicId': _placeholderMusicId,
|
||||
'level': 0,
|
||||
@@ -527,10 +606,48 @@ class _TravelPartnerPageState extends State<TravelPartnerPage> {
|
||||
: theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
AppStrings.travelPartnerIdRangeTitle,
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
AppStrings.travelPartnerIdRangeBody,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
if (_pendingGrant.any(_isSuspectId)) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
AppStrings.travelPartnerIdUnknown,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 客户端 `PlInformationProcess.AddDefaultCharacter` 里的默认发放清单,
|
||||
/// 是已知一定存在于机台 Chara 表的那批 ID。
|
||||
static const List<int> _knownCharacterIds = [
|
||||
101, 102, 103, 104, 105,
|
||||
201, 202, 203, 204, 205,
|
||||
301, 302, 303, 304, 305, 306,
|
||||
392, 393, 394, 395,
|
||||
401, 402, 403, 404, 405,
|
||||
501, 502, 503, 504, 505,
|
||||
601, 602, 603, 604, 605,
|
||||
];
|
||||
|
||||
bool _isSuspectId(int id) => !_knownCharacterIds.contains(id);
|
||||
|
||||
Widget _slotCard(ThemeData theme) {
|
||||
// 下拉选项 = 已拥有 ∪ 待发放 ∪ 当前槽位已有值。
|
||||
final optionIds = <int>{
|
||||
@@ -548,37 +665,64 @@ class _TravelPartnerPageState extends State<TravelPartnerPage> {
|
||||
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,
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
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;
|
||||
}),
|
||||
),
|
||||
for (final id in optionIds)
|
||||
DropdownMenuItem(value: id, child: Text('#$id')),
|
||||
if (_slots[i] != 0) ...[
|
||||
const SizedBox(height: 6),
|
||||
TextField(
|
||||
controller: _slotLevelControllers[i],
|
||||
enabled: !_running,
|
||||
keyboardType: TextInputType.number,
|
||||
onChanged: (_) => setState(() {}),
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
labelText: AppStrings.travelPartnerLevelLabel,
|
||||
hintText: AppStrings.travelPartnerLevelHint,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 14,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
..._levelHintLines(theme, _slotLevelControllers[i].text),
|
||||
],
|
||||
],
|
||||
onChanged: _running
|
||||
? null
|
||||
: (v) => setState(() {
|
||||
final next = [..._slots];
|
||||
next[i] = v ?? 0;
|
||||
_slots = next;
|
||||
}),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
@@ -599,6 +743,25 @@ class _TravelPartnerPageState extends State<TravelPartnerPage> {
|
||||
);
|
||||
}
|
||||
|
||||
/// 真实等级和界面等级不是一回事(`UserChara`:显示 = level % 10000、
|
||||
/// 转生 = level ~/ 10000),只在会混淆时才说明。
|
||||
List<Widget> _levelHintLines(ThemeData theme, String raw) {
|
||||
final level = int.tryParse(raw.trim());
|
||||
if (level == null || level < UserAllPayloadBuilder.minCharacterLevel) {
|
||||
return const [];
|
||||
}
|
||||
if (level <= 9999) return const [];
|
||||
return [
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
AppStrings.travelPartnerLevelConverted(level),
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
Widget _ownedCard(ThemeData theme) {
|
||||
return _sectionCard(
|
||||
theme,
|
||||
|
||||
Reference in new issue
Block a user