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

427 lines
13 KiB
Dart
Raw 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 '../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,
);
}
}