Files
Ichino/lib/pages/map_traverse_page.dart
Bad0RANG3 e9357f5969 refactor: 拆分主页与公共组件,扫码登录改走 WinHTTP
- 抽出 lib/core 的日期/JSON 工具,收敛各页重复解析
- 主页与音乐风险页拆成组件,风险功能共用一套运行框架
- 修复设置页作为主页页签保存时弹掉根路由导致的黑屏
- 登录对齐 Astral aimedb 协议:84 位二维码校验、签名与请求头修正,
  补 GlobalSign 中间证书;Windows 改用 WinHTTP 绕过服务端对
  BoringSSL 的 TLS 指纹拦截(证书校验保持开启)
- 补开发预览数据与测试
2026-10-25 15:51:10 +08:00

328 lines
11 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter/material.dart';
import '../config/strings.dart';
import '../core/json_value.dart';
import '../models/session_model.dart';
import '../services/title_api_service.dart';
import '../services/user_all_payload_builder.dart';
import '../widgets/app_card.dart';
import '../widgets/app_notice.dart';
import '../widgets/cooldown_mixin.dart';
import '../widgets/risk/risk_run_mixin.dart';
import '../widgets/risk/risk_widgets.dart';
import '../widgets/step_progress.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();
}
class _MapTraversePageState extends State<MapTraversePage>
with CooldownMixin<MapTraversePage>, RiskRunMixin<MapTraversePage> {
final _mapIdController = TextEditingController();
final List<int> _pending = [];
Map<String, Map<String, dynamic>> _userAllData = const {};
Set<int>? _serverMapIds;
List<Map<String, dynamic>> _serverMaps = const [];
@override
int? get cooldownLoginDateTime => widget.loginDateTime;
@override
void dispose() {
_mapIdController.dispose();
super.dispose();
}
void _snack(String message) => context.showSnack(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) => jsonInt(e['mapId']))
.where((e) => e != 0)
.toSet();
}
Future<void> _fetchData() async {
final service = SessionModel.instance.createTitleService(
cookies: widget.cookies,
);
if (service == null) return;
setRiskFetching(true);
try {
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>>);
riskFetching = false;
});
} catch (e) {
if (!mounted) return;
setState(() => riskFetching = false);
_snack('${AppStrings.loadFailed}: ${describeApiError(e)}');
}
}
Future<void> _run() async {
final guard = riskPreconditionError(
loginDateTime: widget.loginDateTime,
loginId: widget.loginId,
cooldownRemaining: cooldownRemaining,
notLoggedInMessage: AppStrings.mapNotLoggedIn,
cooldownNotice: AppStrings.mapCooldownNotice,
);
if (guard != null) {
_snack(guard);
return;
}
if (_pending.isEmpty) {
_snack(AppStrings.mapNeedMapId);
return;
}
final loginDateTime = widget.loginDateTime!;
final loginId = widget.loginId!;
setRiskRunning(true);
try {
final service = SessionModel.instance.createTitleService(
cookies: widget.cookies,
)!;
final builder = UserAllPayloadBuilder(service.config);
var data = _userAllData;
var serverIds = _serverMapIds;
if (data.isEmpty || serverIds == null) {
updateRiskStep(RiskStep.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!;
}
updateRiskStep(RiskStep.upload);
final packet = builder.build(
userId: widget.userId,
loginId: loginId,
loginDateTime: loginDateTime,
musicData: UserAllPayloadBuilder.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);
updateRiskStep(RiskStep.complete, await _verify(service));
await autoLogoutAndExit(widget.onExitToTitle);
} catch (e) {
reportRiskError(e);
} finally {
setRiskRunning(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(describeApiError(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);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(title: const Text(AppStrings.mapFeatureTitle)),
body: RiskPageBody(
children: [
RiskStatusNotices(
isLoggedIn: isLoggedIn,
cooldownRemaining: cooldownRemaining,
notLoggedInText: AppStrings.mapNotLoggedIn,
cooldownText: AppStrings.mapCooldownNotice,
),
RiskFeatureCard(
icon: Icons.map_outlined,
title: AppStrings.mapFeatureTitle,
description: AppStrings.mapFeatureDesc,
child: Padding(
padding: const EdgeInsets.only(top: 10),
child: 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),
RiskFetchCard(
hasData: _serverMapIds != null,
statusText: _serverMapIds != null
? '${AppStrings.mapFetched} (${_serverMaps.length})'
: AppStrings.mapNoData,
loadingLabel: AppStrings.travelPartnerFetching,
actionLabel: AppStrings.mapFetchData,
loading: riskFetching,
enabled: !riskRunning,
onFetch: _fetchData,
),
const SizedBox(height: 12),
AutoLogoutToggle(
value: riskAutoLogout,
enabled: widget.onExitToTitle != null && !riskRunning,
onChanged: setRiskAutoLogout,
),
const SizedBox(height: 12),
RiskRunButton(
running: riskRunning,
enabled: isLoggedIn,
icon: Icons.flag_outlined,
label: AppStrings.mapRun,
runningLabel: AppStrings.mapRunning,
onPressed: _run,
),
if (riskStep != RiskStep.idle) ...[
const SizedBox(height: 16),
StepProgressCard(
step: riskStep,
message: riskStepMessage,
steps: [
(RiskStep.fetchData, AppStrings.mapStepFetch),
(RiskStep.upload, AppStrings.mapStepUpload),
if (riskAutoLogout) (RiskStep.logout, AppStrings.stepLogout),
],
),
],
],
),
);
}
Widget _inputCard(ThemeData theme) {
final enabled = !riskRunning;
return AppCard(
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,
),
),
],
),
);
}
}