- 抽出 lib/core 的日期/JSON 工具,收敛各页重复解析 - 主页与音乐风险页拆成组件,风险功能共用一套运行框架 - 修复设置页作为主页页签保存时弹掉根路由导致的黑屏 - 登录对齐 Astral aimedb 协议:84 位二维码校验、签名与请求头修正, 补 GlobalSign 中间证书;Windows 改用 WinHTTP 绕过服务端对 BoringSSL 的 TLS 指纹拦截(证书校验保持开启) - 补开发预览数据与测试
427 lines
13 KiB
Dart
427 lines
13 KiB
Dart
|
||
import 'package:flutter/material.dart';
|
||
|
||
import '../config/strings.dart';
|
||
import '../models/session_model.dart';
|
||
import '../models/user_kaleidx_scope.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.userKaleidxScopeList` 同一行的三个布尔位上
|
||
/// (`isGateFound` / `isKeyFound` / `isClear`)。钥匙不走 `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 _ClearChoice { keep, cleared, uncleared }
|
||
|
||
class _KaleidxScopePageState extends State<KaleidxScopePage>
|
||
with CooldownMixin<KaleidxScopePage>, RiskRunMixin<KaleidxScopePage> {
|
||
final _gateIdController = TextEditingController();
|
||
final List<int> _pending = [];
|
||
|
||
bool _discover = true;
|
||
bool _giveKey = false;
|
||
_ClearChoice _clearChoice = _ClearChoice.keep;
|
||
|
||
Map<String, Map<String, dynamic>> _userAllData = const {};
|
||
List<UserKaleidxScopeBean> _serverScopes = const [];
|
||
|
||
@override
|
||
int? get cooldownLoginDateTime => widget.loginDateTime;
|
||
|
||
@override
|
||
void dispose() {
|
||
_gateIdController.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
void _snack(String message) => context.showSnack(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;
|
||
}
|
||
|
||
bool? get _targetClear => switch (_clearChoice) {
|
||
_ClearChoice.keep => null,
|
||
_ClearChoice.cleared => true,
|
||
_ClearChoice.uncleared => false,
|
||
};
|
||
|
||
/// 整行替换,所以已存在的门必须拿服务器原行做底,只改要改的位。
|
||
List<UserKaleidxScopeBean> _buildRows() {
|
||
final clear = _targetClear;
|
||
final timestamp = UserAllPayloadBuilder.nowTimestamp();
|
||
return [
|
||
for (final id in _pending)
|
||
_serverRow(id)?.withActions(
|
||
discover: _discover,
|
||
giveKey: _giveKey,
|
||
clear: clear,
|
||
timestamp: timestamp,
|
||
) ??
|
||
UserKaleidxScopeBean.discovered(
|
||
id,
|
||
giveKey: _giveKey,
|
||
clear: clear,
|
||
timestamp: timestamp,
|
||
),
|
||
];
|
||
}
|
||
|
||
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.getUserKaleidxScopes(widget.userId),
|
||
]);
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_userAllData = results[0] as Map<String, Map<String, dynamic>>;
|
||
_serverScopes = results[1] as List<UserKaleidxScopeBean>;
|
||
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.kaleidxNotLoggedIn,
|
||
cooldownNotice: AppStrings.kaleidxCooldownNotice,
|
||
);
|
||
if (guard != null) {
|
||
_snack(guard);
|
||
return;
|
||
}
|
||
if (_pending.isEmpty) {
|
||
_snack(AppStrings.kaleidxNeedGateId);
|
||
return;
|
||
}
|
||
if (!_discover && !_giveKey && _clearChoice == _ClearChoice.keep) {
|
||
_snack(AppStrings.kaleidxNeedAction);
|
||
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;
|
||
if (data.isEmpty || _serverScopes.isEmpty) {
|
||
updateRiskStep(RiskStep.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>;
|
||
});
|
||
}
|
||
}
|
||
|
||
updateRiskStep(RiskStep.upload);
|
||
final packet = builder.build(
|
||
userId: widget.userId,
|
||
loginId: loginId,
|
||
loginDateTime: loginDateTime,
|
||
musicData: UserAllPayloadBuilder.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);
|
||
updateRiskStep(RiskStep.complete, await _verify(service));
|
||
await autoLogoutAndExit(widget.onExitToTitle);
|
||
} catch (e) {
|
||
reportRiskError(e);
|
||
} finally {
|
||
setRiskRunning(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(describeApiError(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);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
|
||
return Scaffold(
|
||
appBar: AppBar(title: const Text(AppStrings.kaleidxFeatureTitle)),
|
||
body: RiskPageBody(
|
||
children: [
|
||
RiskStatusNotices(
|
||
isLoggedIn: isLoggedIn,
|
||
cooldownRemaining: cooldownRemaining,
|
||
notLoggedInText: AppStrings.kaleidxNotLoggedIn,
|
||
cooldownText: AppStrings.kaleidxCooldownNotice,
|
||
),
|
||
RiskFeatureCard(
|
||
icon: Icons.science_outlined,
|
||
title: AppStrings.kaleidxFeatureTitle,
|
||
description: AppStrings.kaleidxFeatureDesc,
|
||
),
|
||
const SizedBox(height: 12),
|
||
_inputCard(theme),
|
||
const SizedBox(height: 12),
|
||
RiskFetchCard(
|
||
hasData: _userAllData.isNotEmpty,
|
||
statusText: _userAllData.isNotEmpty
|
||
? '${AppStrings.kaleidxFetched} (${_serverScopes.length})'
|
||
: AppStrings.kaleidxNoData,
|
||
loadingLabel: AppStrings.travelPartnerFetching,
|
||
actionLabel: AppStrings.kaleidxFetchData,
|
||
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.lock_open_outlined,
|
||
label: AppStrings.kaleidxRun,
|
||
runningLabel: AppStrings.kaleidxRunning,
|
||
onPressed: _run,
|
||
),
|
||
if (riskStep != RiskStep.idle) ...[
|
||
const SizedBox(height: 16),
|
||
StepProgressCard(
|
||
step: riskStep,
|
||
message: riskStepMessage,
|
||
steps: [
|
||
(RiskStep.fetchData, AppStrings.kaleidxStepFetch),
|
||
(RiskStep.upload, AppStrings.kaleidxStepUpload),
|
||
if (riskAutoLogout) (RiskStep.logout, AppStrings.stepLogout),
|
||
],
|
||
),
|
||
],
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _inputCard(ThemeData theme) {
|
||
final enabled = !riskRunning;
|
||
return AppCard(
|
||
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),
|
||
RiskCheckRow(
|
||
label: AppStrings.kaleidxActionDiscover,
|
||
value: _discover,
|
||
enabled: enabled,
|
||
onChanged: (v) => setState(() => _discover = v),
|
||
),
|
||
RiskCheckRow(
|
||
label: AppStrings.kaleidxActionKey,
|
||
value: _giveKey,
|
||
enabled: enabled,
|
||
onChanged: (v) => setState(() => _giveKey = v),
|
||
),
|
||
const SizedBox(height: 10),
|
||
DropdownButtonFormField<_ClearChoice>(
|
||
initialValue: _clearChoice,
|
||
decoration: InputDecoration(
|
||
labelText: AppStrings.kaleidxClearLabel,
|
||
border: OutlineInputBorder(
|
||
borderRadius: BorderRadius.circular(10),
|
||
),
|
||
contentPadding: const EdgeInsets.all(14),
|
||
),
|
||
items: const [
|
||
DropdownMenuItem(
|
||
value: _ClearChoice.keep,
|
||
child: Text(AppStrings.kaleidxClearKeep),
|
||
),
|
||
DropdownMenuItem(
|
||
value: _ClearChoice.cleared,
|
||
child: Text(AppStrings.kaleidxClearCleared),
|
||
),
|
||
DropdownMenuItem(
|
||
value: _ClearChoice.uncleared,
|
||
child: Text(AppStrings.kaleidxClearUncleared),
|
||
),
|
||
],
|
||
onChanged: enabled
|
||
? (v) => setState(
|
||
() => _clearChoice = v ?? _ClearChoice.keep,
|
||
)
|
||
: null,
|
||
),
|
||
if (_giveKey || _clearChoice == _ClearChoice.cleared) ...[
|
||
const SizedBox(height: 6),
|
||
Text(
|
||
_giveKey
|
||
? AppStrings.kaleidxKeyNeedsGate
|
||
: AppStrings.kaleidxClearNeedsKey,
|
||
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,
|
||
);
|
||
}
|
||
}
|