Files
Ichino/lib/pages/number_patch_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

411 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 '../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';
/// 数值修改: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,
),
);
}
}
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>
with CooldownMixin<NumberPatchView>, RiskRunMixin<NumberPatchView> {
final _valueController = TextEditingController();
Map<String, Map<String, dynamic>> _userAllData = const {};
bool _maiMileAdd = true;
bool get _isRating => widget.mode == NumberPatchMode.rating;
@override
int? get cooldownLoginDateTime => widget.loginDateTime;
@override
void dispose() {
_valueController.dispose();
super.dispose();
}
void _snack(String message) => context.showSnack(message);
/// 从已拉取的 GetUserDataApi 里读当前值,供「累加」和界面上的当前值展示。
int _currentOf(Map<String, Map<String, dynamic>> data, String field) {
final userData = data['GetUserDataApi']?['userData'];
if (userData is! Map) return 0;
return jsonInt(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 {
final service = SessionModel.instance.createTitleService(
cookies: widget.cookies,
);
if (service == null) return;
setRiskFetching(true);
try {
final data = await service.fetchUserAllData(widget.userId);
if (!mounted) return;
setState(() {
_userAllData = data;
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.numberPatchNotLoggedIn,
cooldownNotice: AppStrings.numberPatchCooldownNotice,
);
if (guard != null) {
_snack(guard);
return;
}
final inputError = _checkInput();
if (inputError != null) {
_snack(inputError);
return;
}
final input = int.parse(_valueController.text.trim());
final loginDateTime = widget.loginDateTime!;
final loginId = widget.loginId!;
setRiskRunning(true);
clearRiskError();
try {
final service = SessionModel.instance.createTitleService(
cookies: widget.cookies,
)!;
final builder = UserAllPayloadBuilder(service.config);
var data = _userAllData;
if (data.isEmpty) {
updateRiskStep(RiskStep.fetchData);
data = await service.fetchUserAllData(widget.userId);
if (mounted) setState(() => _userAllData = data);
}
updateRiskStep(RiskStep.upload);
final packet = builder.build(
userId: widget.userId,
loginId: loginId,
loginDateTime: loginDateTime,
musicData: UserAllPayloadBuilder.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);
updateRiskStep(RiskStep.complete, resultMessage);
await autoLogoutAndExit(widget.onExitToTitle);
} catch (e) {
reportRiskError(e);
} finally {
setRiskRunning(false);
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return RiskPageBody(
children: [
RiskStatusNotices(
isLoggedIn: isLoggedIn,
cooldownRemaining: cooldownRemaining,
notLoggedInText: AppStrings.numberPatchNotLoggedIn,
cooldownText: AppStrings.numberPatchCooldownNotice,
),
RiskFeatureCard(
icon: _isRating ? Icons.speed : Icons.commute,
title: widget.featureTitle,
description: widget.featureDesc,
),
const SizedBox(height: 12),
_inputCard(theme),
const SizedBox(height: 12),
_fetchCard(),
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.send,
label: AppStrings.numberPatchRun,
runningLabel: AppStrings.unlockRunning,
onPressed: _run,
),
if (riskStep != RiskStep.idle) ...[
const SizedBox(height: 16),
StepProgressCard(
step: riskStep,
message: riskStepMessage,
error: riskError,
steps: [
(RiskStep.fetchData, AppStrings.unlockStepFetch),
(RiskStep.upload, AppStrings.numberPatchStepUpload),
if (riskAutoLogout) (RiskStep.logout, AppStrings.stepLogout),
],
),
],
],
);
}
Widget _inputCard(ThemeData theme) {
final enabled = !riskRunning;
final hasInput = int.tryParse(_valueController.text.trim()) != null;
return AppCard(
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() {
final hasData = _userAllData.isNotEmpty;
final current = _isRating ? _currentRating : _currentPoint;
final label = _isRating
? AppStrings.ratingCurrentValue
: AppStrings.maiMileCurrentBalance;
return RiskFetchCard(
hasData: hasData,
statusText: hasData ? '$label: $current' : AppStrings.unlockNoData,
loadingLabel: AppStrings.unlockFetching,
actionLabel:
hasData ? AppStrings.unlockRefetch : AppStrings.unlockFetchData,
loading: riskFetching,
enabled: !riskRunning,
onFetch: _fetchData,
);
}
}