refactor: 抽取高危功能页共享脚手架与传输层日志
诊断:功能票 / 解锁歌曲 / 收藏品 / 旅行伙伴 / 数值修改 / 一键跑图 / 万花筒 / 高危 hub 这些页面各自抄了一份同样的脚手架——冷却计时器、_Step 枚举、自动退登开关、卡片/提示/进度卡;TitleApiService.getUserPreview 还 重复实现了整套传输层;服务层有 37 处裸 print 会把 token / JSESSIONID 打进日志。 - 新增共享原语,7 个功能页切换到: * widgets/cooldown_mixin.dart CooldownMixin(登录后冷却倒计时) * widgets/step_progress.dart RiskStep + StepProgressCard * widgets/app_card.dart AppCard / SectionTitle / AutoLogoutToggle * widgets/app_notice.dart AppNotice + context.showSnack - TitleApiService: getUserPreview 收敛进通用 _callApi;新增 fromHolder() 工厂与 config getter,消除散落的 isConfigured + config! - 传输层 print -> ApiLog(仅 debug 输出),删除全部 avoid_print 忽略 - UserAllPayloadBuilder 新增 placeholderMusicData()(原 5 处重复) - LoginPage 从 main.dart 抽到 pages/login_page.dart,入口只留 main + MyApp - 更新 README / CLAUDE.md 目录结构与已过时的 web/依赖说明 验证:flutter analyze 0 issue(含 info);flutter test 96/96 通过。 纯结构重构,行为保持不变;仅有意统一了功能票 / 音乐风险 / hub 的 卡片与步骤视觉样式。 净删除约 2176 行重复代码。
This commit is contained in:
1 parent
0808e367f7
commit
45214b11a0
21 files changed
+1196
-2176
No files matched your search
+2
-295
@@ -1,12 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'config/responsive.dart';
|
||||
|
||||
import 'config/strings.dart';
|
||||
import 'config/title_server_config.dart';
|
||||
import 'models/session_model.dart';
|
||||
import 'services/file_picker_service.dart';
|
||||
import 'services/qr_service.dart';
|
||||
import 'pages/home_page.dart';
|
||||
import 'pages/settings_page.dart';
|
||||
import 'pages/login_page.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
@@ -39,292 +35,3 @@ class MyApp extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class LoginPage extends StatefulWidget {
|
||||
const LoginPage({super.key});
|
||||
|
||||
@override
|
||||
State<LoginPage> createState() => _LoginPageState();
|
||||
}
|
||||
|
||||
class _LoginPageState extends State<LoginPage> {
|
||||
final _qrController = TextEditingController();
|
||||
String _qrCode = '';
|
||||
bool _loading = false;
|
||||
bool _forcePreviewApi = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_qrController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onQRContentChanged(String value) {
|
||||
setState(() {
|
||||
_qrCode = SessionModel.extractQRCode(value.trim());
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _onUploadQR() async {
|
||||
try {
|
||||
final bytes = await pickImageBytes();
|
||||
if (bytes == null) return;
|
||||
|
||||
final result = await decodeQRFromBytes(bytes);
|
||||
|
||||
if (result != null && result.isNotEmpty) {
|
||||
_qrController.text = result;
|
||||
setState(() {
|
||||
_qrCode = SessionModel.extractQRCode(result);
|
||||
});
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text(AppStrings.qrScanSuccess)),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text(AppStrings.qrScanFailed)),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('${AppStrings.qrScanError}: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onLogin() async {
|
||||
if (_qrCode.isEmpty) return;
|
||||
|
||||
setState(() => _loading = true);
|
||||
|
||||
try {
|
||||
final result = await SessionModel.instance.loginWithQr(_qrCode);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
if (result.success) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => HomePage(
|
||||
userId: result.userId,
|
||||
token: result.token,
|
||||
forcePreviewApi: _forcePreviewApi,
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('${AppStrings.loginFailed} (errorID: ${result.errorId})')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('${AppStrings.requestFailed}: $e')),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text(AppStrings.appTitle),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.settings),
|
||||
tooltip: AppStrings.settingsTooltip,
|
||||
onPressed: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const SettingsPage()),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 48),
|
||||
child: responsiveBody(
|
||||
context,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.qr_code_scanner,
|
||||
size: 64,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
AppStrings.appTitle,
|
||||
style: theme.textTheme.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
AppStrings.loginSubtitle,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
|
||||
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: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.key,
|
||||
size: 18,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
AppStrings.qrCodeToken,
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _qrController,
|
||||
maxLines: 3,
|
||||
onChanged: _onQRContentChanged,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
contentPadding: const EdgeInsets.all(14),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (_qrCode.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest
|
||||
.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
AppStrings.qrParsedResult,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
_qrCode,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 28),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _loading ? null : _onUploadQR,
|
||||
icon: const Icon(Icons.image, size: 20),
|
||||
label: const Text(AppStrings.uploadQR),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
onPressed:
|
||||
(_qrCode.isNotEmpty && !_loading) ? _onLogin : null,
|
||||
icon: _loading
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.arrow_forward, size: 20),
|
||||
label: Text(_loading ? AppStrings.loggingIn : AppStrings.login),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
CheckboxListTile(
|
||||
value: _forcePreviewApi,
|
||||
onChanged: (v) => setState(() => _forcePreviewApi = v ?? false),
|
||||
title: const Text(AppStrings.forcePreviewApi),
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
dense: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -75,10 +75,7 @@ class _Best50PageState extends State<Best50Page> {
|
||||
});
|
||||
|
||||
try {
|
||||
final service = TitleApiService(
|
||||
TitleServerConfigHolder().config!,
|
||||
cookies: widget.cookies,
|
||||
);
|
||||
final service = TitleApiService.fromHolder(cookies: widget.cookies)!;
|
||||
final rating = await service.getUserRatingTyped(widget.userId);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../config/responsive.dart';
|
||||
import '../config/strings.dart';
|
||||
import '../config/title_server_config.dart';
|
||||
import '../widgets/app_notice.dart';
|
||||
import '../widgets/cooldown_mixin.dart';
|
||||
import 'collectibles_page.dart';
|
||||
import 'kaleidx_scope_page.dart';
|
||||
import 'map_traverse_page.dart';
|
||||
@@ -43,56 +43,10 @@ class HighRiskFeaturePage extends StatefulWidget {
|
||||
State<HighRiskFeaturePage> createState() => _HighRiskFeaturePageState();
|
||||
}
|
||||
|
||||
class _HighRiskFeaturePageState extends State<HighRiskFeaturePage> {
|
||||
Timer? _cooldownTimer;
|
||||
int _cooldownRemaining = 0;
|
||||
|
||||
class _HighRiskFeaturePageState extends State<HighRiskFeaturePage>
|
||||
with CooldownMixin<HighRiskFeaturePage> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_syncCooldown();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant HighRiskFeaturePage oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.loginDateTime != widget.loginDateTime) {
|
||||
_syncCooldown();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_cooldownTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _syncCooldown() {
|
||||
_cooldownTimer?.cancel();
|
||||
final loginDateTime = widget.loginDateTime;
|
||||
if (loginDateTime == null) {
|
||||
_cooldownRemaining = 0;
|
||||
return;
|
||||
}
|
||||
_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 elapsed = nowSec - loginDateTime;
|
||||
final remaining = AppStrings.ticketCooldownSeconds - elapsed;
|
||||
return remaining < 0 ? 0 : remaining;
|
||||
}
|
||||
int? get cooldownLoginDateTime => widget.loginDateTime;
|
||||
|
||||
void _openUnlockMusic() {
|
||||
Navigator.of(context).push(
|
||||
@@ -209,9 +163,17 @@ class _HighRiskFeaturePageState extends State<HighRiskFeaturePage> {
|
||||
context,
|
||||
child: Column(
|
||||
children: [
|
||||
if (widget.loginDateTime == null) _buildNotLoggedInBanner(theme),
|
||||
if (widget.loginDateTime != null && _cooldownRemaining > 0)
|
||||
_buildCooldownBanner(theme),
|
||||
if (!isLoggedIn)
|
||||
AppNotice(
|
||||
AppStrings.musicRiskHubNotLoggedIn,
|
||||
error: true,
|
||||
icon: Icons.warning_amber_rounded,
|
||||
),
|
||||
if (isLoggedIn && cooldownRemaining > 0)
|
||||
AppNotice(
|
||||
AppStrings.musicRiskHubCooldownNotice(cooldownRemaining),
|
||||
icon: Icons.timer_outlined,
|
||||
),
|
||||
_buildDescCard(theme),
|
||||
const SizedBox(height: 12),
|
||||
_buildNavCard(
|
||||
@@ -275,60 +237,6 @@ class _HighRiskFeaturePageState extends State<HighRiskFeaturePage> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNotLoggedInBanner(ThemeData theme) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.warning_amber_rounded,
|
||||
size: 18, color: theme.colorScheme.onErrorContainer),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
AppStrings.musicRiskHubNotLoggedIn,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onErrorContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCooldownBanner(ThemeData theme) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.tertiaryContainer.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.timer_outlined,
|
||||
size: 18, color: theme.colorScheme.onTertiaryContainer),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
AppStrings.musicRiskHubCooldownNotice(_cooldownRemaining),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onTertiaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDescCard(ThemeData theme) {
|
||||
return Card(
|
||||
elevation: 0,
|
||||
|
||||
@@ -75,10 +75,7 @@ class _HomePageState extends State<HomePage> {
|
||||
});
|
||||
_session.setGameLogin(null);
|
||||
|
||||
final service = TitleApiService(
|
||||
TitleServerConfigHolder().config!,
|
||||
cookies: _session.cookies,
|
||||
);
|
||||
final service = TitleApiService.fromHolder(cookies: _session.cookies)!;
|
||||
|
||||
try {
|
||||
final preview = await service.getUserPreview(
|
||||
@@ -102,10 +99,8 @@ class _HomePageState extends State<HomePage> {
|
||||
);
|
||||
if (!mounted) return;
|
||||
|
||||
final userDataService = TitleApiService(
|
||||
TitleServerConfigHolder().config!,
|
||||
cookies: _session.cookies,
|
||||
);
|
||||
final userDataService =
|
||||
TitleApiService.fromHolder(cookies: _session.cookies)!;
|
||||
final userData = await userDataService.getUserDataTyped(widget.userId);
|
||||
if (!mounted) return;
|
||||
_session.updateCookies(userDataService.cookies);
|
||||
@@ -142,10 +137,7 @@ class _HomePageState extends State<HomePage> {
|
||||
);
|
||||
if (!mounted) return;
|
||||
|
||||
final service = TitleApiService(
|
||||
TitleServerConfigHolder().config!,
|
||||
cookies: _session.cookies,
|
||||
);
|
||||
final service = TitleApiService.fromHolder(cookies: _session.cookies)!;
|
||||
final userData = await service.getUserDataTyped(widget.userId);
|
||||
if (!mounted) return;
|
||||
_session.updateCookies(service.cookies);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
@@ -8,6 +7,10 @@ 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';
|
||||
import '../widgets/app_card.dart';
|
||||
import '../widgets/app_notice.dart';
|
||||
import '../widgets/cooldown_mixin.dart';
|
||||
import '../widgets/step_progress.dart';
|
||||
|
||||
/// 万花筒专区:发现新的宿命之门 + 获取门的钥匙。
|
||||
///
|
||||
@@ -34,12 +37,8 @@ class KaleidxScopePage extends StatefulWidget {
|
||||
State<KaleidxScopePage> createState() => _KaleidxScopePageState();
|
||||
}
|
||||
|
||||
enum _Step { idle, fetchData, upload, logout, complete, failed }
|
||||
|
||||
class _KaleidxScopePageState extends State<KaleidxScopePage> {
|
||||
/// 与其它高危功能一致:不写真实成绩,playlog 用同一条占位记录。
|
||||
static const int _placeholderMusicId = 11538;
|
||||
|
||||
class _KaleidxScopePageState extends State<KaleidxScopePage>
|
||||
with CooldownMixin<KaleidxScopePage> {
|
||||
final _gateIdController = TextEditingController();
|
||||
final List<int> _pending = [];
|
||||
|
||||
@@ -52,65 +51,19 @@ class _KaleidxScopePageState extends State<KaleidxScopePage> {
|
||||
bool _loading = false;
|
||||
bool _running = false;
|
||||
bool _autoLogout = true;
|
||||
_Step _step = _Step.idle;
|
||||
RiskStep _step = RiskStep.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();
|
||||
}
|
||||
}
|
||||
int? get cooldownLoginDateTime => widget.loginDateTime;
|
||||
|
||||
@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 _snack(String message) => context.showSnack(message);
|
||||
|
||||
void _addPending() {
|
||||
final id = int.tryParse(_gateIdController.text.trim()) ?? -1;
|
||||
@@ -149,8 +102,7 @@ class _KaleidxScopePageState extends State<KaleidxScopePage> {
|
||||
setState(() => _loading = true);
|
||||
|
||||
try {
|
||||
final config = TitleServerConfigHolder().config!;
|
||||
final service = TitleApiService(config, cookies: widget.cookies);
|
||||
final service = TitleApiService.fromHolder(cookies: widget.cookies)!;
|
||||
final results = await Future.wait([
|
||||
service.fetchUserAllData(widget.userId),
|
||||
service.getUserKaleidxScopes(widget.userId),
|
||||
@@ -179,8 +131,8 @@ class _KaleidxScopePageState extends State<KaleidxScopePage> {
|
||||
_snack(AppStrings.kaleidxNotLoggedIn);
|
||||
return;
|
||||
}
|
||||
if (_cooldownRemaining > 0) {
|
||||
_snack(AppStrings.kaleidxCooldownNotice(_cooldownRemaining));
|
||||
if (cooldownRemaining > 0) {
|
||||
_snack(AppStrings.kaleidxCooldownNotice(cooldownRemaining));
|
||||
return;
|
||||
}
|
||||
if (_pending.isEmpty) {
|
||||
@@ -195,13 +147,12 @@ class _KaleidxScopePageState extends State<KaleidxScopePage> {
|
||||
setState(() => _running = true);
|
||||
|
||||
try {
|
||||
final config = TitleServerConfigHolder().config!;
|
||||
final service = TitleApiService(config, cookies: widget.cookies);
|
||||
final builder = UserAllPayloadBuilder(config);
|
||||
final service = TitleApiService.fromHolder(cookies: widget.cookies)!;
|
||||
final builder = UserAllPayloadBuilder(service.config);
|
||||
|
||||
var data = _userAllData;
|
||||
if (data.isEmpty || _serverScopes.isEmpty) {
|
||||
_updateStep(_Step.fetchData);
|
||||
_updateStep(RiskStep.fetchData);
|
||||
final results = await Future.wait([
|
||||
service.fetchUserAllData(widget.userId),
|
||||
service.getUserKaleidxScopes(widget.userId),
|
||||
@@ -215,12 +166,12 @@ class _KaleidxScopePageState extends State<KaleidxScopePage> {
|
||||
}
|
||||
}
|
||||
|
||||
_updateStep(_Step.upload);
|
||||
_updateStep(RiskStep.upload);
|
||||
final packet = builder.build(
|
||||
userId: widget.userId,
|
||||
loginId: loginId,
|
||||
loginDateTime: loginDateTime,
|
||||
musicData: _placeholderMusicData(),
|
||||
musicData: UserAllPayloadBuilder.placeholderMusicData(),
|
||||
generalUserInfo: data,
|
||||
);
|
||||
|
||||
@@ -236,18 +187,18 @@ class _KaleidxScopePageState extends State<KaleidxScopePage> {
|
||||
);
|
||||
|
||||
await service.upsertUserAll(packet, widget.userId);
|
||||
_updateStep(_Step.complete, await _verify(service));
|
||||
_updateStep(RiskStep.complete, await _verify(service));
|
||||
|
||||
if (_autoLogout && widget.onExitToTitle != null) {
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
if (!mounted) return;
|
||||
_updateStep(_Step.logout, AppStrings.exitingToTitle);
|
||||
_updateStep(RiskStep.logout, AppStrings.exitingToTitle);
|
||||
await widget.onExitToTitle!();
|
||||
}
|
||||
} on TitleApiException catch (e) {
|
||||
_updateStep(_Step.failed, e.message);
|
||||
_updateStep(RiskStep.failed, e.message);
|
||||
} catch (e) {
|
||||
_updateStep(_Step.failed, '$e');
|
||||
_updateStep(RiskStep.failed, '$e');
|
||||
} finally {
|
||||
if (mounted) setState(() => _running = false);
|
||||
}
|
||||
@@ -273,19 +224,7 @@ class _KaleidxScopePageState extends State<KaleidxScopePage> {
|
||||
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]) {
|
||||
void _updateStep(RiskStep step, [String? message]) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_step = step;
|
||||
@@ -306,22 +245,19 @@ class _KaleidxScopePageState extends State<KaleidxScopePage> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (!_loggedIn)
|
||||
_notice(theme, AppStrings.kaleidxNotLoggedIn, error: true),
|
||||
if (_loggedIn && _cooldownRemaining > 0)
|
||||
_notice(
|
||||
theme,
|
||||
AppStrings.kaleidxCooldownNotice(_cooldownRemaining),
|
||||
if (!isLoggedIn)
|
||||
const AppNotice(AppStrings.kaleidxNotLoggedIn, error: true),
|
||||
if (isLoggedIn && cooldownRemaining > 0)
|
||||
AppNotice(
|
||||
AppStrings.kaleidxCooldownNotice(cooldownRemaining),
|
||||
),
|
||||
_card(
|
||||
theme,
|
||||
AppCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_titleRow(
|
||||
theme,
|
||||
Icons.science_outlined,
|
||||
AppStrings.kaleidxFeatureTitle,
|
||||
const SectionTitle(
|
||||
icon: Icons.science_outlined,
|
||||
title: AppStrings.kaleidxFeatureTitle,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
@@ -339,12 +275,24 @@ class _KaleidxScopePageState extends State<KaleidxScopePage> {
|
||||
const SizedBox(height: 12),
|
||||
_fetchCard(theme),
|
||||
const SizedBox(height: 12),
|
||||
_autoLogoutToggle(theme),
|
||||
AutoLogoutToggle(
|
||||
value: _autoLogout,
|
||||
enabled: widget.onExitToTitle != null && !_running,
|
||||
onChanged: (v) => setState(() => _autoLogout = v),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_runButton(theme),
|
||||
if (_step != _Step.idle) ...[
|
||||
if (_step != RiskStep.idle) ...[
|
||||
const SizedBox(height: 16),
|
||||
_progressCard(theme),
|
||||
StepProgressCard(
|
||||
step: _step,
|
||||
message: _stepMessage,
|
||||
steps: [
|
||||
(RiskStep.fetchData, AppStrings.kaleidxStepFetch),
|
||||
(RiskStep.upload, AppStrings.kaleidxStepUpload),
|
||||
if (_autoLogout) (RiskStep.logout, AppStrings.stepLogout),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
@@ -353,59 +301,6 @@ class _KaleidxScopePageState extends State<KaleidxScopePage> {
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -429,8 +324,7 @@ class _KaleidxScopePageState extends State<KaleidxScopePage> {
|
||||
|
||||
Widget _inputCard(ThemeData theme) {
|
||||
final enabled = !_running;
|
||||
return _card(
|
||||
theme,
|
||||
return AppCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
@@ -521,8 +415,7 @@ class _KaleidxScopePageState extends State<KaleidxScopePage> {
|
||||
|
||||
Widget _fetchCard(ThemeData theme) {
|
||||
final hasData = _userAllData.isNotEmpty;
|
||||
return _card(
|
||||
theme,
|
||||
return AppCard(
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
@@ -573,38 +466,11 @@ class _KaleidxScopePageState extends State<KaleidxScopePage> {
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
onPressed: _running || !isLoggedIn ? null : _run,
|
||||
icon: _running
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
@@ -624,72 +490,4 @@ class _KaleidxScopePageState extends State<KaleidxScopePage> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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,299 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../config/responsive.dart';
|
||||
import '../config/strings.dart';
|
||||
import '../models/session_model.dart';
|
||||
import '../services/file_picker_service.dart';
|
||||
import '../services/qr_service.dart';
|
||||
import 'home_page.dart';
|
||||
import 'settings_page.dart';
|
||||
|
||||
/// 登录页:粘贴 / 识别 QR 令牌,走 Aime 登录后进入主页。
|
||||
class LoginPage extends StatefulWidget {
|
||||
const LoginPage({super.key});
|
||||
|
||||
@override
|
||||
State<LoginPage> createState() => _LoginPageState();
|
||||
}
|
||||
|
||||
class _LoginPageState extends State<LoginPage> {
|
||||
final _qrController = TextEditingController();
|
||||
String _qrCode = '';
|
||||
bool _loading = false;
|
||||
bool _forcePreviewApi = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_qrController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onQRContentChanged(String value) {
|
||||
setState(() {
|
||||
_qrCode = SessionModel.extractQRCode(value.trim());
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _onUploadQR() async {
|
||||
try {
|
||||
final bytes = await pickImageBytes();
|
||||
if (bytes == null) return;
|
||||
|
||||
final result = await decodeQRFromBytes(bytes);
|
||||
|
||||
if (result != null && result.isNotEmpty) {
|
||||
_qrController.text = result;
|
||||
setState(() {
|
||||
_qrCode = SessionModel.extractQRCode(result);
|
||||
});
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text(AppStrings.qrScanSuccess)),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text(AppStrings.qrScanFailed)),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('${AppStrings.qrScanError}: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onLogin() async {
|
||||
if (_qrCode.isEmpty) return;
|
||||
|
||||
setState(() => _loading = true);
|
||||
|
||||
try {
|
||||
final result = await SessionModel.instance.loginWithQr(_qrCode);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
if (result.success) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => HomePage(
|
||||
userId: result.userId,
|
||||
token: result.token,
|
||||
forcePreviewApi: _forcePreviewApi,
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('${AppStrings.loginFailed} (errorID: ${result.errorId})')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('${AppStrings.requestFailed}: $e')),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text(AppStrings.appTitle),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.settings),
|
||||
tooltip: AppStrings.settingsTooltip,
|
||||
onPressed: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const SettingsPage()),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 48),
|
||||
child: responsiveBody(
|
||||
context,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.qr_code_scanner,
|
||||
size: 64,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
AppStrings.appTitle,
|
||||
style: theme.textTheme.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
AppStrings.loginSubtitle,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
|
||||
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: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.key,
|
||||
size: 18,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
AppStrings.qrCodeToken,
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _qrController,
|
||||
maxLines: 3,
|
||||
onChanged: _onQRContentChanged,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
contentPadding: const EdgeInsets.all(14),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (_qrCode.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest
|
||||
.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
AppStrings.qrParsedResult,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
_qrCode,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 28),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _loading ? null : _onUploadQR,
|
||||
icon: const Icon(Icons.image, size: 20),
|
||||
label: const Text(AppStrings.uploadQR),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
onPressed:
|
||||
(_qrCode.isNotEmpty && !_loading) ? _onLogin : null,
|
||||
icon: _loading
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.arrow_forward, size: 20),
|
||||
label: Text(_loading ? AppStrings.loggingIn : AppStrings.login),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
CheckboxListTile(
|
||||
value: _forcePreviewApi,
|
||||
onChanged: (v) => setState(() => _forcePreviewApi = v ?? false),
|
||||
title: const Text(AppStrings.forcePreviewApi),
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
dense: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
@@ -7,6 +6,10 @@ import '../config/strings.dart';
|
||||
import '../config/title_server_config.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/step_progress.dart';
|
||||
|
||||
/// 一键跑图:把选中的区域标记为已完成。
|
||||
///
|
||||
@@ -39,12 +42,8 @@ class MapTraversePage extends StatefulWidget {
|
||||
State<MapTraversePage> createState() => _MapTraversePageState();
|
||||
}
|
||||
|
||||
enum _Step { idle, fetchData, upload, logout, complete, failed }
|
||||
|
||||
class _MapTraversePageState extends State<MapTraversePage> {
|
||||
/// 与其它高危功能一致:不写真实成绩,playlog 用同一条占位记录。
|
||||
static const int _placeholderMusicId = 11538;
|
||||
|
||||
class _MapTraversePageState extends State<MapTraversePage>
|
||||
with CooldownMixin<MapTraversePage> {
|
||||
final _mapIdController = TextEditingController();
|
||||
final List<int> _pending = [];
|
||||
|
||||
@@ -55,65 +54,19 @@ class _MapTraversePageState extends State<MapTraversePage> {
|
||||
bool _loading = false;
|
||||
bool _running = false;
|
||||
bool _autoLogout = true;
|
||||
_Step _step = _Step.idle;
|
||||
RiskStep _step = RiskStep.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();
|
||||
}
|
||||
}
|
||||
int? get cooldownLoginDateTime => widget.loginDateTime;
|
||||
|
||||
@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 _snack(String message) => context.showSnack(message);
|
||||
|
||||
void _addPending() {
|
||||
final id = int.tryParse(_mapIdController.text.trim()) ?? -1;
|
||||
@@ -144,8 +97,7 @@ class _MapTraversePageState extends State<MapTraversePage> {
|
||||
setState(() => _loading = true);
|
||||
|
||||
try {
|
||||
final config = TitleServerConfigHolder().config!;
|
||||
final service = TitleApiService(config, cookies: widget.cookies);
|
||||
final service = TitleApiService.fromHolder(cookies: widget.cookies)!;
|
||||
final results = await Future.wait([
|
||||
service.fetchUserAllData(widget.userId),
|
||||
service.getUserMaps(widget.userId),
|
||||
@@ -174,8 +126,8 @@ class _MapTraversePageState extends State<MapTraversePage> {
|
||||
_snack(AppStrings.mapNotLoggedIn);
|
||||
return;
|
||||
}
|
||||
if (_cooldownRemaining > 0) {
|
||||
_snack(AppStrings.mapCooldownNotice(_cooldownRemaining));
|
||||
if (cooldownRemaining > 0) {
|
||||
_snack(AppStrings.mapCooldownNotice(cooldownRemaining));
|
||||
return;
|
||||
}
|
||||
if (_pending.isEmpty) {
|
||||
@@ -186,14 +138,13 @@ class _MapTraversePageState extends State<MapTraversePage> {
|
||||
setState(() => _running = true);
|
||||
|
||||
try {
|
||||
final config = TitleServerConfigHolder().config!;
|
||||
final service = TitleApiService(config, cookies: widget.cookies);
|
||||
final builder = UserAllPayloadBuilder(config);
|
||||
final service = TitleApiService.fromHolder(cookies: widget.cookies)!;
|
||||
final builder = UserAllPayloadBuilder(service.config);
|
||||
|
||||
var data = _userAllData;
|
||||
var serverIds = _serverMapIds;
|
||||
if (data.isEmpty || serverIds == null) {
|
||||
_updateStep(_Step.fetchData);
|
||||
_updateStep(RiskStep.fetchData);
|
||||
final results = await Future.wait([
|
||||
service.fetchUserAllData(widget.userId),
|
||||
service.getUserMaps(widget.userId),
|
||||
@@ -204,12 +155,12 @@ class _MapTraversePageState extends State<MapTraversePage> {
|
||||
serverIds = _serverMapIds!;
|
||||
}
|
||||
|
||||
_updateStep(_Step.upload);
|
||||
_updateStep(RiskStep.upload);
|
||||
final packet = builder.build(
|
||||
userId: widget.userId,
|
||||
loginId: loginId,
|
||||
loginDateTime: loginDateTime,
|
||||
musicData: _placeholderMusicData(),
|
||||
musicData: UserAllPayloadBuilder.placeholderMusicData(),
|
||||
generalUserInfo: data,
|
||||
);
|
||||
|
||||
@@ -224,18 +175,18 @@ class _MapTraversePageState extends State<MapTraversePage> {
|
||||
builder.applyMapPatch(packet, maps: maps, newFlags: flags);
|
||||
|
||||
await service.upsertUserAll(packet, widget.userId);
|
||||
_updateStep(_Step.complete, await _verify(service));
|
||||
_updateStep(RiskStep.complete, await _verify(service));
|
||||
|
||||
if (_autoLogout && widget.onExitToTitle != null) {
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
if (!mounted) return;
|
||||
_updateStep(_Step.logout, AppStrings.exitingToTitle);
|
||||
_updateStep(RiskStep.logout, AppStrings.exitingToTitle);
|
||||
await widget.onExitToTitle!();
|
||||
}
|
||||
} on TitleApiException catch (e) {
|
||||
_updateStep(_Step.failed, e.message);
|
||||
_updateStep(RiskStep.failed, e.message);
|
||||
} catch (e) {
|
||||
_updateStep(_Step.failed, '$e');
|
||||
_updateStep(RiskStep.failed, '$e');
|
||||
} finally {
|
||||
if (mounted) setState(() => _running = false);
|
||||
}
|
||||
@@ -260,19 +211,7 @@ class _MapTraversePageState extends State<MapTraversePage> {
|
||||
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]) {
|
||||
void _updateStep(RiskStep step, [String? message]) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_step = step;
|
||||
@@ -293,19 +232,17 @@ class _MapTraversePageState extends State<MapTraversePage> {
|
||||
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,
|
||||
if (!isLoggedIn)
|
||||
const AppNotice(AppStrings.mapNotLoggedIn, error: true),
|
||||
if (isLoggedIn && cooldownRemaining > 0)
|
||||
AppNotice(AppStrings.mapCooldownNotice(cooldownRemaining)),
|
||||
AppCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_titleRow(
|
||||
theme,
|
||||
Icons.map_outlined,
|
||||
AppStrings.mapFeatureTitle,
|
||||
const SectionTitle(
|
||||
icon: Icons.map_outlined,
|
||||
title: AppStrings.mapFeatureTitle,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
@@ -331,12 +268,24 @@ class _MapTraversePageState extends State<MapTraversePage> {
|
||||
const SizedBox(height: 12),
|
||||
_fetchCard(theme),
|
||||
const SizedBox(height: 12),
|
||||
_autoLogoutToggle(theme),
|
||||
AutoLogoutToggle(
|
||||
value: _autoLogout,
|
||||
enabled: widget.onExitToTitle != null && !_running,
|
||||
onChanged: (v) => setState(() => _autoLogout = v),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_runButton(theme),
|
||||
if (_step != _Step.idle) ...[
|
||||
if (_step != RiskStep.idle) ...[
|
||||
const SizedBox(height: 16),
|
||||
_progressCard(theme),
|
||||
StepProgressCard(
|
||||
step: _step,
|
||||
message: _stepMessage,
|
||||
steps: [
|
||||
(RiskStep.fetchData, AppStrings.mapStepFetch),
|
||||
(RiskStep.upload, AppStrings.mapStepUpload),
|
||||
if (_autoLogout) (RiskStep.logout, AppStrings.stepLogout),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
@@ -345,63 +294,9 @@ class _MapTraversePageState extends State<MapTraversePage> {
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
return AppCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
@@ -449,8 +344,7 @@ class _MapTraversePageState extends State<MapTraversePage> {
|
||||
|
||||
Widget _fetchCard(ThemeData theme) {
|
||||
final hasData = _serverMapIds != null;
|
||||
return _card(
|
||||
theme,
|
||||
return AppCard(
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
@@ -501,38 +395,11 @@ class _MapTraversePageState extends State<MapTraversePage> {
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
onPressed: _running || !isLoggedIn ? null : _run,
|
||||
icon: _running
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
@@ -552,72 +419,4 @@ class _MapTraversePageState extends State<MapTraversePage> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../config/responsive.dart';
|
||||
@@ -7,6 +5,10 @@ import '../config/strings.dart';
|
||||
import '../config/title_server_config.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/step_progress.dart';
|
||||
|
||||
/// Dart port of `eaquira/action/UnlockMusic.py`:
|
||||
///
|
||||
@@ -15,8 +17,6 @@ import '../services/user_all_payload_builder.dart';
|
||||
/// - 附加道具 (userItemList / isNewItemList)
|
||||
/// - UpsertUserAllApi 上传
|
||||
/// - 可选自动 UserLogout
|
||||
enum MusicRiskStep { idle, fetchData, upload, logout, complete, failed }
|
||||
|
||||
enum MusicRiskFeatureMode {
|
||||
/// 解锁歌曲与谱面 (itemKind 5/6/7)
|
||||
unlockMusic,
|
||||
@@ -95,10 +95,8 @@ class MusicRiskFeatureView extends StatefulWidget {
|
||||
State<MusicRiskFeatureView> createState() => _MusicRiskFeatureViewState();
|
||||
}
|
||||
|
||||
class _MusicRiskFeatureViewState extends State<MusicRiskFeatureView> {
|
||||
/// eaquira `settings.musicData` 默认歌曲 (Amber Chronicle)。
|
||||
static const int _defaultMusicId = 11538;
|
||||
|
||||
class _MusicRiskFeatureViewState extends State<MusicRiskFeatureView>
|
||||
with CooldownMixin<MusicRiskFeatureView> {
|
||||
// ── 解锁选项 (UnlockMusic) ──
|
||||
final _unlockMusicIdController = TextEditingController();
|
||||
bool _unlockMusic = false;
|
||||
@@ -122,66 +120,23 @@ class _MusicRiskFeatureViewState extends State<MusicRiskFeatureView> {
|
||||
String? _fetchError;
|
||||
bool _running = false;
|
||||
bool _autoLogout = true;
|
||||
MusicRiskStep _step = MusicRiskStep.idle;
|
||||
RiskStep _step = RiskStep.idle;
|
||||
String _stepMessage = '';
|
||||
String? _error;
|
||||
|
||||
// ── 冷却 ──
|
||||
Timer? _cooldownTimer;
|
||||
int _cooldownRemaining = 0;
|
||||
|
||||
bool get _isUnlock => widget.mode == MusicRiskFeatureMode.unlockMusic;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_syncCooldown();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant MusicRiskFeatureView oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.loginDateTime != widget.loginDateTime) {
|
||||
_syncCooldown();
|
||||
}
|
||||
}
|
||||
int? get cooldownLoginDateTime => widget.loginDateTime;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_cooldownTimer?.cancel();
|
||||
_unlockMusicIdController.dispose();
|
||||
_itemIdController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _syncCooldown() {
|
||||
_cooldownTimer?.cancel();
|
||||
final loginDateTime = widget.loginDateTime;
|
||||
if (loginDateTime == null) {
|
||||
_cooldownRemaining = 0;
|
||||
return;
|
||||
}
|
||||
_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 elapsed = nowSec - loginDateTime;
|
||||
final remaining = AppStrings.ticketCooldownSeconds - elapsed;
|
||||
return remaining < 0 ? 0 : remaining;
|
||||
}
|
||||
|
||||
void _updateStep(MusicRiskStep step, [String? message]) {
|
||||
void _updateStep(RiskStep step, [String? message]) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_step = step;
|
||||
@@ -194,11 +149,7 @@ class _MusicRiskFeatureViewState extends State<MusicRiskFeatureView> {
|
||||
|
||||
int _itemId() => int.tryParse(_itemIdController.text.trim()) ?? -1;
|
||||
|
||||
void _snack(String message) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(message)),
|
||||
);
|
||||
}
|
||||
void _snack(String message) => context.showSnack(message);
|
||||
|
||||
/// 把当前输入框里的收藏品加入待提交列表。
|
||||
void _addItemToList() {
|
||||
@@ -245,21 +196,10 @@ class _MusicRiskFeatureViewState extends State<MusicRiskFeatureView> {
|
||||
/// 解锁状态本身由 userItemList 的 itemKind 5/6/7 行表达,这里只保留一条
|
||||
/// 记录以维持 payload 形状,取列表首条歌曲 ID。
|
||||
Map<String, dynamic> _buildMusicData() {
|
||||
return {
|
||||
'musicId': _isUnlock
|
||||
? (_pendingMusics.isNotEmpty
|
||||
? _pendingMusics.first.musicId
|
||||
: _defaultMusicId)
|
||||
: _defaultMusicId,
|
||||
'level': 0,
|
||||
'playCount': 1,
|
||||
'achievement': 0,
|
||||
'comboStatus': 0,
|
||||
'syncStatus': 0,
|
||||
'deluxscoreMax': 0,
|
||||
'scoreRank': 0,
|
||||
'extNum1': 0,
|
||||
};
|
||||
final musicId = _isUnlock && _pendingMusics.isNotEmpty
|
||||
? _pendingMusics.first.musicId
|
||||
: UserAllPayloadBuilder.placeholderMusicId;
|
||||
return UserAllPayloadBuilder.placeholderMusicData(musicId: musicId);
|
||||
}
|
||||
|
||||
/// 汇总成 wire 上的 userItemList 行。
|
||||
@@ -307,8 +247,7 @@ class _MusicRiskFeatureViewState extends State<MusicRiskFeatureView> {
|
||||
});
|
||||
|
||||
try {
|
||||
final config = TitleServerConfigHolder().config!;
|
||||
final service = TitleApiService(config, cookies: widget.cookies);
|
||||
final service = TitleApiService.fromHolder(cookies: widget.cookies)!;
|
||||
final data = await service.fetchUserAllData(widget.userId);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
@@ -327,34 +266,25 @@ class _MusicRiskFeatureViewState extends State<MusicRiskFeatureView> {
|
||||
Future<void> _run() async {
|
||||
final validationError = _validateInputs();
|
||||
if (validationError != null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(validationError)),
|
||||
);
|
||||
_snack(validationError);
|
||||
return;
|
||||
}
|
||||
if (!TitleServerConfigHolder().isConfigured) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text(AppStrings.ticketNotConfigured)),
|
||||
);
|
||||
_snack(AppStrings.ticketNotConfigured);
|
||||
return;
|
||||
}
|
||||
final loginDateTime = widget.loginDateTime;
|
||||
final loginId = widget.loginId;
|
||||
if (loginDateTime == null || loginId == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(_notLoggedInMessage)),
|
||||
);
|
||||
_snack(_notLoggedInMessage);
|
||||
return;
|
||||
}
|
||||
if (_cooldownRemaining > 0) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(_cooldownNotice(_cooldownRemaining))),
|
||||
);
|
||||
if (cooldownRemaining > 0) {
|
||||
_snack(_cooldownNotice(cooldownRemaining));
|
||||
return;
|
||||
}
|
||||
|
||||
final config = TitleServerConfigHolder().config!;
|
||||
final service = TitleApiService(config, cookies: widget.cookies);
|
||||
final service = TitleApiService.fromHolder(cookies: widget.cookies)!;
|
||||
|
||||
setState(() {
|
||||
_running = true;
|
||||
@@ -364,14 +294,14 @@ class _MusicRiskFeatureViewState extends State<MusicRiskFeatureView> {
|
||||
try {
|
||||
Map<String, Map<String, dynamic>> data = _userAllData ?? const {};
|
||||
if (data.isEmpty) {
|
||||
_updateStep(MusicRiskStep.fetchData);
|
||||
_updateStep(RiskStep.fetchData);
|
||||
data = await service.fetchUserAllData(widget.userId);
|
||||
if (mounted) setState(() => _userAllData = data);
|
||||
}
|
||||
|
||||
_updateStep(MusicRiskStep.upload);
|
||||
_updateStep(RiskStep.upload);
|
||||
final musicData = _buildMusicData();
|
||||
final builder = UserAllPayloadBuilder(config);
|
||||
final builder = UserAllPayloadBuilder(service.config);
|
||||
final packet = builder.build(
|
||||
userId: widget.userId,
|
||||
loginId: loginId,
|
||||
@@ -388,7 +318,7 @@ class _MusicRiskFeatureViewState extends State<MusicRiskFeatureView> {
|
||||
await service.upsertUserAll(packet, widget.userId);
|
||||
|
||||
_updateStep(
|
||||
MusicRiskStep.complete,
|
||||
RiskStep.complete,
|
||||
_isUnlock
|
||||
? AppStrings.unlockMusicSuccess
|
||||
: AppStrings.collectiblesSuccess,
|
||||
@@ -398,14 +328,14 @@ class _MusicRiskFeatureViewState extends State<MusicRiskFeatureView> {
|
||||
// 让成功状态先显示一下,再交给 HomePage 结算、退登并 pop 回主标题。
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
if (!mounted) return;
|
||||
_updateStep(MusicRiskStep.logout, AppStrings.exitingToTitle);
|
||||
_updateStep(RiskStep.logout, AppStrings.exitingToTitle);
|
||||
await widget.onExitToTitle!();
|
||||
}
|
||||
} on TitleApiException catch (e) {
|
||||
_updateStep(MusicRiskStep.failed, e.message);
|
||||
_updateStep(RiskStep.failed, e.message);
|
||||
setState(() => _error = e.message);
|
||||
} catch (e) {
|
||||
_updateStep(MusicRiskStep.failed, e.toString());
|
||||
_updateStep(RiskStep.failed, e.toString());
|
||||
setState(() => _error = e.toString());
|
||||
} finally {
|
||||
if (mounted) setState(() => _running = false);
|
||||
@@ -448,9 +378,17 @@ class _MusicRiskFeatureViewState extends State<MusicRiskFeatureView> {
|
||||
context,
|
||||
child: Column(
|
||||
children: [
|
||||
if (widget.loginDateTime == null) _buildNotLoggedInBanner(theme),
|
||||
if (widget.loginDateTime != null && _cooldownRemaining > 0)
|
||||
_buildCooldownBanner(theme),
|
||||
if (!isLoggedIn)
|
||||
AppNotice(
|
||||
_notLoggedInMessage,
|
||||
error: true,
|
||||
icon: Icons.warning_amber_rounded,
|
||||
),
|
||||
if (isLoggedIn && cooldownRemaining > 0)
|
||||
AppNotice(
|
||||
_cooldownNotice(cooldownRemaining),
|
||||
icon: Icons.timer_outlined,
|
||||
),
|
||||
_buildDescCard(theme),
|
||||
const SizedBox(height: 12),
|
||||
if (_isUnlock)
|
||||
@@ -460,12 +398,30 @@ class _MusicRiskFeatureViewState extends State<MusicRiskFeatureView> {
|
||||
const SizedBox(height: 12),
|
||||
_buildFetchCard(theme),
|
||||
const SizedBox(height: 12),
|
||||
_buildAutoLogoutToggle(theme),
|
||||
AutoLogoutToggle(
|
||||
value: _autoLogout,
|
||||
enabled: widget.onExitToTitle != null && !_running,
|
||||
onChanged: (v) => setState(() => _autoLogout = v),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildRunButton(theme),
|
||||
if (_step != MusicRiskStep.idle) ...[
|
||||
if (_step != RiskStep.idle) ...[
|
||||
const SizedBox(height: 16),
|
||||
_buildProgressCard(theme),
|
||||
StepProgressCard(
|
||||
step: _step,
|
||||
message: _stepMessage,
|
||||
error: _error,
|
||||
steps: [
|
||||
(RiskStep.fetchData, AppStrings.unlockStepFetch),
|
||||
(
|
||||
RiskStep.upload,
|
||||
_isUnlock
|
||||
? AppStrings.unlockStepUpload
|
||||
: AppStrings.collectiblesStepUpload,
|
||||
),
|
||||
if (_autoLogout) (RiskStep.logout, AppStrings.stepLogout),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
@@ -473,60 +429,6 @@ class _MusicRiskFeatureViewState extends State<MusicRiskFeatureView> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNotLoggedInBanner(ThemeData theme) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.warning_amber_rounded,
|
||||
size: 18, color: theme.colorScheme.onErrorContainer),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_notLoggedInMessage,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onErrorContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCooldownBanner(ThemeData theme) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.tertiaryContainer.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.timer_outlined,
|
||||
size: 18, color: theme.colorScheme.onTertiaryContainer),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_cooldownNotice(_cooldownRemaining),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onTertiaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDescCard(ThemeData theme) {
|
||||
return Card(
|
||||
elevation: 0,
|
||||
@@ -913,42 +815,8 @@ class _MusicRiskFeatureViewState extends State<MusicRiskFeatureView> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAutoLogoutToggle(ThemeData theme) {
|
||||
final enabled = widget.onExitToTitle != null && !_running;
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
onTap: enabled
|
||||
? () => setState(() => _autoLogout = !_autoLogout)
|
||||
: null,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Checkbox(
|
||||
value: _autoLogout,
|
||||
onChanged: enabled
|
||||
? (v) => setState(() => _autoLogout = v ?? false)
|
||||
: null,
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
AppStrings.autoLogoutAndExit,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: enabled
|
||||
? null
|
||||
: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRunButton(ThemeData theme) {
|
||||
final onCooldown = _cooldownRemaining > 0;
|
||||
final onCooldown = cooldownRemaining > 0;
|
||||
final canRun = widget.loginDateTime != null &&
|
||||
widget.loginId != null &&
|
||||
!_running &&
|
||||
@@ -974,7 +842,7 @@ class _MusicRiskFeatureViewState extends State<MusicRiskFeatureView> {
|
||||
_running
|
||||
? AppStrings.unlockRunning
|
||||
: onCooldown
|
||||
? AppStrings.ticketCooldownCountdown(_cooldownRemaining)
|
||||
? AppStrings.ticketCooldownCountdown(cooldownRemaining)
|
||||
: _isUnlock
|
||||
? AppStrings.unlockMusicRun
|
||||
: AppStrings.collectiblesRun,
|
||||
@@ -989,125 +857,6 @@ class _MusicRiskFeatureViewState extends State<MusicRiskFeatureView> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProgressCard(ThemeData theme) {
|
||||
final isFailed = _step == MusicRiskStep.failed;
|
||||
final isDone = _step == MusicRiskStep.complete;
|
||||
|
||||
return Card(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(
|
||||
color: isFailed
|
||||
? theme.colorScheme.error.withValues(alpha: 0.5)
|
||||
: isDone
|
||||
? Colors.green.withValues(alpha: 0.5)
|
||||
: theme.colorScheme.outline.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_stepRow(MusicRiskStep.fetchData, AppStrings.unlockStepFetch, theme),
|
||||
_stepRow(
|
||||
MusicRiskStep.upload,
|
||||
_isUnlock
|
||||
? AppStrings.unlockStepUpload
|
||||
: AppStrings.collectiblesStepUpload,
|
||||
theme,
|
||||
),
|
||||
if (_autoLogout)
|
||||
_stepRow(MusicRiskStep.logout, AppStrings.stepLogout, theme),
|
||||
if (_stepMessage.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: isFailed
|
||||
? theme.colorScheme.errorContainer
|
||||
: isDone
|
||||
? Colors.green.withValues(alpha: 0.1)
|
||||
: theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
_stepMessage,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
color: isFailed
|
||||
? theme.colorScheme.onErrorContainer
|
||||
: theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
_error!,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onErrorContainer,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _stepRow(MusicRiskStep step, String label, ThemeData theme) {
|
||||
IconData icon;
|
||||
Color? color;
|
||||
|
||||
if (_step == MusicRiskStep.failed && _step.index <= step.index) {
|
||||
icon = _step == step ? Icons.error : Icons.circle_outlined;
|
||||
color = _step == step
|
||||
? theme.colorScheme.error
|
||||
: theme.colorScheme.onSurfaceVariant;
|
||||
} else if (_step.index > step.index) {
|
||||
icon = Icons.check_circle;
|
||||
color = Colors.green;
|
||||
} else if (_step == step) {
|
||||
icon = Icons.sync;
|
||||
color = theme.colorScheme.primary;
|
||||
} else {
|
||||
icon = Icons.circle_outlined;
|
||||
color = theme.colorScheme.onSurfaceVariant;
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 3),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, size: 16, color: color),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
label,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: _step.index >= step.index
|
||||
? theme.colorScheme.onSurface
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
fontWeight: _step == step ? FontWeight.w600 : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDropdown({
|
||||
required int value,
|
||||
required String label,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
@@ -7,6 +6,10 @@ import '../config/strings.dart';
|
||||
import '../config/title_server_config.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/step_progress.dart';
|
||||
|
||||
/// 数值修改:Rating 与舞里程。两者的流程一模一样(拉数据 → 改一个数 →
|
||||
/// UpsertUserAll),差别只在改哪个字段,所以合成一个带模式的视图。
|
||||
@@ -65,8 +68,6 @@ class NumberPatchPage extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
enum _Step { idle, fetchData, upload, logout, complete, failed }
|
||||
|
||||
class NumberPatchView extends StatefulWidget {
|
||||
final int userId;
|
||||
final String? cookies;
|
||||
@@ -96,11 +97,8 @@ class NumberPatchView extends StatefulWidget {
|
||||
State<NumberPatchView> createState() => _NumberPatchViewState();
|
||||
}
|
||||
|
||||
class _NumberPatchViewState extends State<NumberPatchView> {
|
||||
/// 与解锁/收藏品/旅行伙伴一致:这些操作不写真实成绩,
|
||||
/// playlog 沿用同一条占位记录,Rating 修改需要它来带 before/after。
|
||||
static const int _placeholderMusicId = 11538;
|
||||
|
||||
class _NumberPatchViewState extends State<NumberPatchView>
|
||||
with CooldownMixin<NumberPatchView> {
|
||||
final _valueController = TextEditingController();
|
||||
|
||||
Map<String, Map<String, dynamic>> _userAllData = const {};
|
||||
@@ -109,68 +107,21 @@ class _NumberPatchViewState extends State<NumberPatchView> {
|
||||
bool _loading = false;
|
||||
bool _running = false;
|
||||
bool _autoLogout = true;
|
||||
_Step _step = _Step.idle;
|
||||
RiskStep _step = RiskStep.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();
|
||||
}
|
||||
}
|
||||
int? get cooldownLoginDateTime => widget.loginDateTime;
|
||||
|
||||
@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)),
|
||||
);
|
||||
}
|
||||
void _snack(String message) => context.showSnack(message);
|
||||
|
||||
static int _toInt(dynamic v) {
|
||||
if (v is int) return v;
|
||||
@@ -225,8 +176,7 @@ class _NumberPatchViewState extends State<NumberPatchView> {
|
||||
setState(() => _loading = true);
|
||||
|
||||
try {
|
||||
final config = TitleServerConfigHolder().config!;
|
||||
final service = TitleApiService(config, cookies: widget.cookies);
|
||||
final service = TitleApiService.fromHolder(cookies: widget.cookies)!;
|
||||
final data = await service.fetchUserAllData(widget.userId);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
@@ -251,8 +201,8 @@ class _NumberPatchViewState extends State<NumberPatchView> {
|
||||
_snack(AppStrings.numberPatchNotLoggedIn);
|
||||
return;
|
||||
}
|
||||
if (_cooldownRemaining > 0) {
|
||||
_snack(AppStrings.numberPatchCooldownNotice(_cooldownRemaining));
|
||||
if (cooldownRemaining > 0) {
|
||||
_snack(AppStrings.numberPatchCooldownNotice(cooldownRemaining));
|
||||
return;
|
||||
}
|
||||
final inputError = _checkInput();
|
||||
@@ -265,23 +215,22 @@ class _NumberPatchViewState extends State<NumberPatchView> {
|
||||
setState(() => _running = true);
|
||||
|
||||
try {
|
||||
final config = TitleServerConfigHolder().config!;
|
||||
final service = TitleApiService(config, cookies: widget.cookies);
|
||||
final builder = UserAllPayloadBuilder(config);
|
||||
final service = TitleApiService.fromHolder(cookies: widget.cookies)!;
|
||||
final builder = UserAllPayloadBuilder(service.config);
|
||||
|
||||
var data = _userAllData;
|
||||
if (data.isEmpty) {
|
||||
_updateStep(_Step.fetchData);
|
||||
_updateStep(RiskStep.fetchData);
|
||||
data = await service.fetchUserAllData(widget.userId);
|
||||
if (mounted) setState(() => _userAllData = data);
|
||||
}
|
||||
|
||||
_updateStep(_Step.upload);
|
||||
_updateStep(RiskStep.upload);
|
||||
final packet = builder.build(
|
||||
userId: widget.userId,
|
||||
loginId: loginId,
|
||||
loginDateTime: loginDateTime,
|
||||
musicData: _placeholderMusicData(),
|
||||
musicData: UserAllPayloadBuilder.placeholderMusicData(),
|
||||
generalUserInfo: data,
|
||||
);
|
||||
|
||||
@@ -304,36 +253,24 @@ class _NumberPatchViewState extends State<NumberPatchView> {
|
||||
}
|
||||
|
||||
await service.upsertUserAll(packet, widget.userId);
|
||||
_updateStep(_Step.complete, resultMessage);
|
||||
_updateStep(RiskStep.complete, resultMessage);
|
||||
|
||||
if (_autoLogout && widget.onExitToTitle != null) {
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
if (!mounted) return;
|
||||
_updateStep(_Step.logout, AppStrings.exitingToTitle);
|
||||
_updateStep(RiskStep.logout, AppStrings.exitingToTitle);
|
||||
await widget.onExitToTitle!();
|
||||
}
|
||||
} on TitleApiException catch (e) {
|
||||
_updateStep(_Step.failed, e.message);
|
||||
_updateStep(RiskStep.failed, e.message);
|
||||
} catch (e) {
|
||||
_updateStep(_Step.failed, '$e');
|
||||
_updateStep(RiskStep.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]) {
|
||||
void _updateStep(RiskStep step, [String? message]) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_step = step;
|
||||
@@ -352,12 +289,11 @@ class _NumberPatchViewState extends State<NumberPatchView> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (!_loggedIn)
|
||||
_notice(theme, AppStrings.numberPatchNotLoggedIn, error: true),
|
||||
if (_loggedIn && _cooldownRemaining > 0)
|
||||
_notice(
|
||||
theme,
|
||||
AppStrings.numberPatchCooldownNotice(_cooldownRemaining),
|
||||
if (!isLoggedIn)
|
||||
const AppNotice(AppStrings.numberPatchNotLoggedIn, error: true),
|
||||
if (isLoggedIn && cooldownRemaining > 0)
|
||||
AppNotice(
|
||||
AppStrings.numberPatchCooldownNotice(cooldownRemaining),
|
||||
),
|
||||
_descCard(theme),
|
||||
const SizedBox(height: 12),
|
||||
@@ -365,12 +301,24 @@ class _NumberPatchViewState extends State<NumberPatchView> {
|
||||
const SizedBox(height: 12),
|
||||
_fetchCard(theme),
|
||||
const SizedBox(height: 12),
|
||||
_autoLogoutToggle(theme),
|
||||
AutoLogoutToggle(
|
||||
value: _autoLogout,
|
||||
enabled: widget.onExitToTitle != null && !_running,
|
||||
onChanged: (v) => setState(() => _autoLogout = v),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_runButton(theme),
|
||||
if (_step != _Step.idle) ...[
|
||||
if (_step != RiskStep.idle) ...[
|
||||
const SizedBox(height: 16),
|
||||
_progressCard(theme),
|
||||
StepProgressCard(
|
||||
step: _step,
|
||||
message: _stepMessage,
|
||||
steps: [
|
||||
(RiskStep.fetchData, AppStrings.unlockStepFetch),
|
||||
(RiskStep.upload, AppStrings.numberPatchStepUpload),
|
||||
if (_autoLogout) (RiskStep.logout, AppStrings.stepLogout),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
@@ -378,65 +326,14 @@ class _NumberPatchViewState extends State<NumberPatchView> {
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
return AppCard(
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
SectionTitle(
|
||||
icon: _isRating ? Icons.speed : Icons.commute,
|
||||
title: widget.featureTitle,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
@@ -455,8 +352,7 @@ class _NumberPatchViewState extends State<NumberPatchView> {
|
||||
final enabled = !_running;
|
||||
final hasInput = int.tryParse(_valueController.text.trim()) != null;
|
||||
|
||||
return _card(
|
||||
theme,
|
||||
return AppCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -541,8 +437,7 @@ class _NumberPatchViewState extends State<NumberPatchView> {
|
||||
? AppStrings.ratingCurrentValue
|
||||
: AppStrings.maiMileCurrentBalance;
|
||||
|
||||
return _card(
|
||||
theme,
|
||||
return AppCard(
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
@@ -596,38 +491,11 @@ class _NumberPatchViewState extends State<NumberPatchView> {
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
onPressed: _running || !isLoggedIn ? null : _run,
|
||||
icon: _running
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
@@ -647,73 +515,4 @@ class _NumberPatchViewState extends State<NumberPatchView> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+52
-292
@@ -1,19 +1,13 @@
|
||||
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';
|
||||
|
||||
enum TicketStep {
|
||||
idle,
|
||||
chargeTicket,
|
||||
logout,
|
||||
complete,
|
||||
failed,
|
||||
}
|
||||
import '../widgets/app_card.dart';
|
||||
import '../widgets/app_notice.dart';
|
||||
import '../widgets/cooldown_mixin.dart';
|
||||
import '../widgets/step_progress.dart';
|
||||
|
||||
class TicketPage extends StatefulWidget {
|
||||
final int userId;
|
||||
@@ -40,14 +34,15 @@ class TicketPage extends StatefulWidget {
|
||||
State<TicketPage> createState() => _TicketPageState();
|
||||
}
|
||||
|
||||
class _TicketPageState extends State<TicketPage> {
|
||||
class _TicketPageState extends State<TicketPage>
|
||||
with CooldownMixin<TicketPage> {
|
||||
static const _ticketNameMap = {
|
||||
2: '2倍票',
|
||||
4: '4倍票',
|
||||
5: '5倍票'
|
||||
};
|
||||
|
||||
TicketStep _step = TicketStep.idle;
|
||||
RiskStep _step = RiskStep.idle;
|
||||
String _stepMessage = '';
|
||||
String? _error;
|
||||
bool _running = false;
|
||||
@@ -58,58 +53,13 @@ class _TicketPageState extends State<TicketPage> {
|
||||
String? _ticketsError;
|
||||
int? _selectedTicketId;
|
||||
|
||||
Timer? _cooldownTimer;
|
||||
int _cooldownRemaining = 0;
|
||||
|
||||
String _ticketName(int chargeId) =>
|
||||
_ticketNameMap[chargeId] ?? '票$chargeId';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_syncCooldown();
|
||||
}
|
||||
int? get cooldownLoginDateTime => widget.loginDateTime;
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant TicketPage oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.loginDateTime != widget.loginDateTime) {
|
||||
_syncCooldown();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_cooldownTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _syncCooldown() {
|
||||
_cooldownTimer?.cancel();
|
||||
final loginDateTime = widget.loginDateTime;
|
||||
if (loginDateTime == null) {
|
||||
_cooldownRemaining = 0;
|
||||
return;
|
||||
}
|
||||
_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 elapsed = nowSec - loginDateTime;
|
||||
final remaining = AppStrings.ticketCooldownSeconds - elapsed;
|
||||
return remaining < 0 ? 0 : remaining;
|
||||
}
|
||||
void _snack(String message) => context.showSnack(message);
|
||||
|
||||
Map<String, dynamic>? _findTicket(int chargeId) {
|
||||
final tickets = _tickets;
|
||||
@@ -120,7 +70,7 @@ class _TicketPageState extends State<TicketPage> {
|
||||
return null;
|
||||
}
|
||||
|
||||
void _updateStep(TicketStep step, [String? message]) {
|
||||
void _updateStep(RiskStep step, [String? message]) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_step = step;
|
||||
@@ -139,8 +89,7 @@ class _TicketPageState extends State<TicketPage> {
|
||||
});
|
||||
|
||||
try {
|
||||
final config = TitleServerConfigHolder().config!;
|
||||
final service = TitleApiService(config, cookies: widget.cookies);
|
||||
final service = TitleApiService.fromHolder(cookies: widget.cookies)!;
|
||||
final data = await service.getUserCharge(widget.userId);
|
||||
if (!mounted) return;
|
||||
|
||||
@@ -160,44 +109,31 @@ class _TicketPageState extends State<TicketPage> {
|
||||
|
||||
Future<void> _runTicket() async {
|
||||
if (_selectedTicketId == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text(AppStrings.ticketNotSelected)),
|
||||
);
|
||||
_snack(AppStrings.ticketNotSelected);
|
||||
return;
|
||||
}
|
||||
if (!TitleServerConfigHolder().isConfigured) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text(AppStrings.ticketNotConfigured)),
|
||||
);
|
||||
_snack(AppStrings.ticketNotConfigured);
|
||||
return;
|
||||
}
|
||||
final loginDateTime = widget.loginDateTime;
|
||||
if (loginDateTime == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text(AppStrings.ticketNotLoggedIn)),
|
||||
);
|
||||
_snack(AppStrings.ticketNotLoggedIn);
|
||||
return;
|
||||
}
|
||||
if (_cooldownRemaining > 0) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(AppStrings.ticketCooldownNotice(_cooldownRemaining)),
|
||||
),
|
||||
);
|
||||
if (cooldownRemaining > 0) {
|
||||
_snack(AppStrings.ticketCooldownNotice(cooldownRemaining));
|
||||
return;
|
||||
}
|
||||
|
||||
final selectedTicket = _findTicket(_selectedTicketId!);
|
||||
if (selectedTicket != null &&
|
||||
(selectedTicket['stock'] as num?)?.toInt() != 0) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text(AppStrings.ticketStockNotEmpty)),
|
||||
);
|
||||
_snack(AppStrings.ticketStockNotEmpty);
|
||||
return;
|
||||
}
|
||||
|
||||
final config = TitleServerConfigHolder().config!;
|
||||
final service = TitleApiService(config, cookies: widget.cookies);
|
||||
final service = TitleApiService.fromHolder(cookies: widget.cookies)!;
|
||||
final ticketId = _selectedTicketId!;
|
||||
|
||||
setState(() {
|
||||
@@ -206,7 +142,7 @@ class _TicketPageState extends State<TicketPage> {
|
||||
});
|
||||
|
||||
try {
|
||||
_updateStep(TicketStep.chargeTicket);
|
||||
_updateStep(RiskStep.upload);
|
||||
await service.upsertUserChargeLog(
|
||||
userId: widget.userId,
|
||||
ticketId: ticketId,
|
||||
@@ -215,20 +151,20 @@ class _TicketPageState extends State<TicketPage> {
|
||||
);
|
||||
|
||||
if (_autoLogout && widget.onLogoutRequested != null) {
|
||||
_updateStep(TicketStep.logout);
|
||||
_updateStep(RiskStep.logout);
|
||||
await Future.delayed(const Duration(seconds: 5));
|
||||
await widget.onLogoutRequested!();
|
||||
}
|
||||
|
||||
_updateStep(
|
||||
TicketStep.complete,
|
||||
RiskStep.complete,
|
||||
'${AppStrings.ticketUsed}: ${_ticketName(ticketId)}',
|
||||
);
|
||||
} on TitleApiException catch (e) {
|
||||
_updateStep(TicketStep.failed, e.message);
|
||||
_updateStep(RiskStep.failed, e.message);
|
||||
setState(() => _error = e.message);
|
||||
} catch (e) {
|
||||
_updateStep(TicketStep.failed, e.toString());
|
||||
_updateStep(RiskStep.failed, e.toString());
|
||||
setState(() => _error = e.toString());
|
||||
} finally {
|
||||
if (mounted) setState(() => _running = false);
|
||||
@@ -261,17 +197,38 @@ class _TicketPageState extends State<TicketPage> {
|
||||
context,
|
||||
child: Column(
|
||||
children: [
|
||||
if (widget.loginDateTime == null) _buildNotLoggedInBanner(theme),
|
||||
if (widget.loginDateTime != null && _cooldownRemaining > 0)
|
||||
_buildCooldownBanner(theme),
|
||||
if (!isLoggedIn)
|
||||
const AppNotice(
|
||||
AppStrings.ticketNotLoggedIn,
|
||||
error: true,
|
||||
icon: Icons.warning_amber_rounded,
|
||||
),
|
||||
if (isLoggedIn && cooldownRemaining > 0)
|
||||
AppNotice(
|
||||
AppStrings.ticketCooldownNotice(cooldownRemaining),
|
||||
icon: Icons.timer_outlined,
|
||||
),
|
||||
_buildTicketListCard(theme),
|
||||
const SizedBox(height: 16),
|
||||
_buildAutoLogoutToggle(theme),
|
||||
AutoLogoutToggle(
|
||||
value: _autoLogout,
|
||||
enabled: widget.onLogoutRequested != null && !_running,
|
||||
onChanged: (v) => setState(() => _autoLogout = v),
|
||||
label: AppStrings.ticketAutoLogout,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildRunButton(theme),
|
||||
if (_step != TicketStep.idle) ...[
|
||||
if (_step != RiskStep.idle) ...[
|
||||
const SizedBox(height: 16),
|
||||
_buildProgressCard(theme),
|
||||
StepProgressCard(
|
||||
step: _step,
|
||||
message: _stepMessage,
|
||||
error: _error,
|
||||
steps: [
|
||||
(RiskStep.upload, AppStrings.stepChargeTicket),
|
||||
if (_autoLogout) (RiskStep.logout, AppStrings.stepLogout),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
@@ -279,94 +236,6 @@ class _TicketPageState extends State<TicketPage> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCooldownBanner(ThemeData theme) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.tertiaryContainer.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.timer_outlined,
|
||||
size: 18, color: theme.colorScheme.onTertiaryContainer),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
AppStrings.ticketCooldownNotice(_cooldownRemaining),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onTertiaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNotLoggedInBanner(ThemeData theme) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.warning_amber_rounded,
|
||||
size: 18, color: theme.colorScheme.onErrorContainer),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
AppStrings.ticketNotLoggedIn,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onErrorContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAutoLogoutToggle(ThemeData theme) {
|
||||
final enabled = widget.onLogoutRequested != null && !_running;
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
onTap: enabled
|
||||
? () => setState(() => _autoLogout = !_autoLogout)
|
||||
: null,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Checkbox(
|
||||
value: _autoLogout,
|
||||
onChanged: enabled
|
||||
? (v) => setState(() => _autoLogout = v ?? false)
|
||||
: null,
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
AppStrings.ticketAutoLogout,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: enabled
|
||||
? null
|
||||
: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTicketListCard(ThemeData theme) {
|
||||
return Card(
|
||||
elevation: 0,
|
||||
@@ -545,7 +414,7 @@ class _TicketPageState extends State<TicketPage> {
|
||||
final selectedName = _selectedTicketId != null
|
||||
? _ticketName(_selectedTicketId!)
|
||||
: null;
|
||||
final onCooldown = _cooldownRemaining > 0;
|
||||
final onCooldown = cooldownRemaining > 0;
|
||||
final canRun = widget.loginDateTime != null && !_running && !onCooldown;
|
||||
|
||||
return Column(
|
||||
@@ -583,7 +452,7 @@ class _TicketPageState extends State<TicketPage> {
|
||||
_running
|
||||
? AppStrings.runningTicket
|
||||
: onCooldown
|
||||
? AppStrings.ticketCooldownCountdown(_cooldownRemaining)
|
||||
? AppStrings.ticketCooldownCountdown(cooldownRemaining)
|
||||
: AppStrings.runTicket,
|
||||
),
|
||||
style: FilledButton.styleFrom(
|
||||
@@ -598,113 +467,4 @@ class _TicketPageState extends State<TicketPage> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProgressCard(ThemeData theme) {
|
||||
final isFailed = _step == TicketStep.failed;
|
||||
final isDone = _step == TicketStep.complete;
|
||||
|
||||
return Card(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(
|
||||
color: isFailed
|
||||
? theme.colorScheme.error.withValues(alpha: 0.5)
|
||||
: isDone
|
||||
? Colors.green.withValues(alpha: 0.5)
|
||||
: theme.colorScheme.outline.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_stepRow(TicketStep.chargeTicket, AppStrings.stepChargeTicket, theme),
|
||||
if (_autoLogout)
|
||||
_stepRow(TicketStep.logout, AppStrings.stepLogout, theme),
|
||||
if (_stepMessage.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: isFailed
|
||||
? theme.colorScheme.errorContainer
|
||||
: isDone
|
||||
? Colors.green.withValues(alpha: 0.1)
|
||||
: theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
_stepMessage,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
color: isFailed
|
||||
? theme.colorScheme.onErrorContainer
|
||||
: theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
_error!,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onErrorContainer,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _stepRow(TicketStep step, String label, ThemeData theme) {
|
||||
IconData icon;
|
||||
Color? color;
|
||||
|
||||
if (_step == TicketStep.failed && _step.index <= step.index) {
|
||||
icon = _step == step ? Icons.error : Icons.circle_outlined;
|
||||
color = _step == step ? theme.colorScheme.error : theme.colorScheme.onSurfaceVariant;
|
||||
} else if (_step.index > step.index) {
|
||||
icon = Icons.check_circle;
|
||||
color = Colors.green;
|
||||
} else if (_step == step) {
|
||||
icon = Icons.sync;
|
||||
color = theme.colorScheme.primary;
|
||||
} else {
|
||||
icon = Icons.circle_outlined;
|
||||
color = theme.colorScheme.onSurfaceVariant;
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 3),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, size: 16, color: color),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
label,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: _step.index >= step.index
|
||||
? theme.colorScheme.onSurface
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
fontWeight: _step == step ? FontWeight.w600 : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
@@ -8,6 +7,10 @@ import '../config/title_server_config.dart';
|
||||
import '../models/user_character.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/step_progress.dart';
|
||||
|
||||
/// 旅行伙伴页:发放角色 + 编组出战槽位。
|
||||
///
|
||||
@@ -40,12 +43,8 @@ class TravelPartnerPage extends StatefulWidget {
|
||||
State<TravelPartnerPage> createState() => _TravelPartnerPageState();
|
||||
}
|
||||
|
||||
enum _Step { idle, fetchData, upload, logout, complete, failed }
|
||||
|
||||
class _TravelPartnerPageState extends State<TravelPartnerPage> {
|
||||
/// 与解锁/收藏品一致:这些操作不写真实成绩,playlog 沿用同一条占位记录。
|
||||
static const int _placeholderMusicId = 11538;
|
||||
|
||||
class _TravelPartnerPageState extends State<TravelPartnerPage>
|
||||
with CooldownMixin<TravelPartnerPage> {
|
||||
final _grantIdController = TextEditingController();
|
||||
|
||||
/// 每个出战槽位一个等级输入框,留空表示不改该角色的等级。
|
||||
@@ -62,31 +61,14 @@ class _TravelPartnerPageState extends State<TravelPartnerPage> {
|
||||
bool _loading = false;
|
||||
bool _running = false;
|
||||
bool _autoLogout = true;
|
||||
_Step _step = _Step.idle;
|
||||
RiskStep _step = RiskStep.idle;
|
||||
String _stepMessage = '';
|
||||
|
||||
Timer? _cooldownTimer;
|
||||
int _cooldownRemaining = 0;
|
||||
|
||||
bool get _loggedIn => widget.loginDateTime != null;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_syncCooldown();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant TravelPartnerPage oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.loginDateTime != widget.loginDateTime) {
|
||||
_syncCooldown();
|
||||
}
|
||||
}
|
||||
int? get cooldownLoginDateTime => widget.loginDateTime;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_cooldownTimer?.cancel();
|
||||
_grantIdController.dispose();
|
||||
for (final c in _slotLevelControllers) {
|
||||
c.dispose();
|
||||
@@ -94,38 +76,7 @@ class _TravelPartnerPageState extends State<TravelPartnerPage> {
|
||||
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 _snack(String message) => context.showSnack(message);
|
||||
|
||||
bool _isAvailable(int characterId) =>
|
||||
characterId != 0 &&
|
||||
@@ -137,8 +88,7 @@ class _TravelPartnerPageState extends State<TravelPartnerPage> {
|
||||
setState(() => _loading = true);
|
||||
|
||||
try {
|
||||
final config = TitleServerConfigHolder().config!;
|
||||
final service = TitleApiService(config, cookies: widget.cookies);
|
||||
final service = TitleApiService.fromHolder(cookies: widget.cookies)!;
|
||||
final results = await Future.wait([
|
||||
service.getUserCharacters(widget.userId),
|
||||
service.fetchUserAllData(widget.userId),
|
||||
@@ -252,8 +202,8 @@ class _TravelPartnerPageState extends State<TravelPartnerPage> {
|
||||
_snack(AppStrings.travelPartnerNotLoggedIn);
|
||||
return;
|
||||
}
|
||||
if (_cooldownRemaining > 0) {
|
||||
_snack(AppStrings.travelPartnerCooldownNotice(_cooldownRemaining));
|
||||
if (cooldownRemaining > 0) {
|
||||
_snack(AppStrings.travelPartnerCooldownNotice(cooldownRemaining));
|
||||
return;
|
||||
}
|
||||
final validationError = _validate();
|
||||
@@ -265,23 +215,22 @@ class _TravelPartnerPageState extends State<TravelPartnerPage> {
|
||||
setState(() => _running = true);
|
||||
|
||||
try {
|
||||
final config = TitleServerConfigHolder().config!;
|
||||
final service = TitleApiService(config, cookies: widget.cookies);
|
||||
final builder = UserAllPayloadBuilder(config);
|
||||
final service = TitleApiService.fromHolder(cookies: widget.cookies)!;
|
||||
final builder = UserAllPayloadBuilder(service.config);
|
||||
|
||||
var data = _userAllData;
|
||||
if (data.isEmpty) {
|
||||
_updateStep(_Step.fetchData);
|
||||
_updateStep(RiskStep.fetchData);
|
||||
data = await service.fetchUserAllData(widget.userId);
|
||||
if (mounted) setState(() => _userAllData = data);
|
||||
}
|
||||
|
||||
_updateStep(_Step.upload);
|
||||
_updateStep(RiskStep.upload);
|
||||
final packet = builder.build(
|
||||
userId: widget.userId,
|
||||
loginId: loginId,
|
||||
loginDateTime: loginDateTime,
|
||||
musicData: _placeholderMusicData(),
|
||||
musicData: UserAllPayloadBuilder.placeholderMusicData(),
|
||||
generalUserInfo: data,
|
||||
);
|
||||
|
||||
@@ -325,7 +274,7 @@ class _TravelPartnerPageState extends State<TravelPartnerPage> {
|
||||
// 服务器没写入 userCharacterList,还是写入了但 ID 不在机台 Chara 表里
|
||||
// 被客户端 CharacterSelectProces 静默跳过。
|
||||
_updateStep(
|
||||
_Step.complete,
|
||||
RiskStep.complete,
|
||||
_pendingGrant.isEmpty
|
||||
? AppStrings.travelPartnerSuccess
|
||||
: await _verifyGranted(service),
|
||||
@@ -334,13 +283,13 @@ class _TravelPartnerPageState extends State<TravelPartnerPage> {
|
||||
if (_autoLogout && widget.onExitToTitle != null) {
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
if (!mounted) return;
|
||||
_updateStep(_Step.logout, AppStrings.exitingToTitle);
|
||||
_updateStep(RiskStep.logout, AppStrings.exitingToTitle);
|
||||
await widget.onExitToTitle!();
|
||||
}
|
||||
} on TitleApiException catch (e) {
|
||||
_updateStep(_Step.failed, e.message);
|
||||
_updateStep(RiskStep.failed, e.message);
|
||||
} catch (e) {
|
||||
_updateStep(_Step.failed, '$e');
|
||||
_updateStep(RiskStep.failed, '$e');
|
||||
} finally {
|
||||
if (mounted) setState(() => _running = false);
|
||||
}
|
||||
@@ -362,19 +311,7 @@ class _TravelPartnerPageState extends State<TravelPartnerPage> {
|
||||
return AppStrings.travelPartnerNotSaved(missing.join(' / '));
|
||||
}
|
||||
|
||||
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]) {
|
||||
void _updateStep(RiskStep step, [String? message]) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_step = step;
|
||||
@@ -397,22 +334,19 @@ class _TravelPartnerPageState extends State<TravelPartnerPage> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (!_loggedIn)
|
||||
_notice(
|
||||
theme,
|
||||
if (!isLoggedIn)
|
||||
const AppNotice(
|
||||
AppStrings.travelPartnerNotLoggedIn,
|
||||
error: true,
|
||||
),
|
||||
if (_loggedIn && _cooldownRemaining > 0)
|
||||
_notice(
|
||||
theme,
|
||||
AppStrings.travelPartnerCooldownNotice(_cooldownRemaining),
|
||||
if (isLoggedIn && cooldownRemaining > 0)
|
||||
AppNotice(
|
||||
AppStrings.travelPartnerCooldownNotice(cooldownRemaining),
|
||||
),
|
||||
_sectionCard(
|
||||
theme,
|
||||
icon: Icons.card_giftcard,
|
||||
title: AppStrings.travelPartnerFeatureDesc,
|
||||
children: const [],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_fetchCard(theme),
|
||||
@@ -423,12 +357,30 @@ class _TravelPartnerPageState extends State<TravelPartnerPage> {
|
||||
const SizedBox(height: 12),
|
||||
_ownedCard(theme),
|
||||
const SizedBox(height: 12),
|
||||
_autoLogoutToggle(theme),
|
||||
AutoLogoutToggle(
|
||||
value: _autoLogout,
|
||||
enabled: widget.onExitToTitle != null && !_running,
|
||||
onChanged: (v) => setState(() => _autoLogout = v),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_runButton(theme),
|
||||
if (_step != _Step.idle) ...[
|
||||
if (_step != RiskStep.idle) ...[
|
||||
const SizedBox(height: 16),
|
||||
_progressCard(theme),
|
||||
StepProgressCard(
|
||||
step: _step,
|
||||
message: _stepMessage,
|
||||
steps: [
|
||||
(
|
||||
RiskStep.fetchData,
|
||||
AppStrings.travelPartnerStepFetch,
|
||||
),
|
||||
(
|
||||
RiskStep.upload,
|
||||
AppStrings.travelPartnerStepUpload,
|
||||
),
|
||||
if (_autoLogout) (RiskStep.logout, AppStrings.stepLogout),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
@@ -437,77 +389,26 @@ class _TravelPartnerPageState extends State<TravelPartnerPage> {
|
||||
);
|
||||
}
|
||||
|
||||
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 _sectionCard(
|
||||
ThemeData theme, {
|
||||
required IconData icon,
|
||||
required String title,
|
||||
required List<Widget> children,
|
||||
List<Widget> children = const [],
|
||||
}) {
|
||||
return _card(
|
||||
theme,
|
||||
return AppCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SectionTitle(icon: icon, title: title),
|
||||
...children,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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 _fetchCard(ThemeData theme) {
|
||||
final hasData = _userAllData.isNotEmpty && _owned.isNotEmpty;
|
||||
return _card(
|
||||
theme,
|
||||
return AppCard(
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
@@ -806,38 +707,11 @@ class _TravelPartnerPageState extends State<TravelPartnerPage> {
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
onPressed: _running || !isLoggedIn ? null : _run,
|
||||
icon: _running
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
@@ -857,73 +731,4 @@ class _TravelPartnerPageState extends State<TravelPartnerPage> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _progressCard(ThemeData theme) {
|
||||
final isFailed = _step == _Step.failed;
|
||||
return _card(
|
||||
theme,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_stepRow(theme, _Step.fetchData, AppStrings.travelPartnerStepFetch),
|
||||
_stepRow(theme, _Step.upload, AppStrings.travelPartnerStepUpload),
|
||||
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,18 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// 传输层调试日志。
|
||||
///
|
||||
/// 之前 `TitleApiService` / `ApiService` 里是 30 多处裸 `print` + `avoid_print`
|
||||
/// 忽略注释,把请求包、响应包、`token`、`JSESSIONID` 全打到 stdout。这里统一
|
||||
/// 收口:
|
||||
/// - 默认只在 debug 构建输出([enabled] 跟随 [kDebugMode]),release 下静默;
|
||||
/// - 需要单独抓包时可以设置 `ApiLog.enabled = true`。
|
||||
class ApiLog {
|
||||
ApiLog._();
|
||||
|
||||
static bool enabled = kDebugMode;
|
||||
|
||||
static void log(String message) {
|
||||
if (enabled) debugPrint(message);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import 'package:crypto/crypto.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../config/title_server_config.dart';
|
||||
import 'api_log.dart';
|
||||
|
||||
class LoginResult {
|
||||
final bool success;
|
||||
@@ -71,10 +72,8 @@ class ApiService {
|
||||
'timestamp': timestamp,
|
||||
});
|
||||
|
||||
// ignore: avoid_print
|
||||
print('[login] POST $_aimeUrl');
|
||||
// ignore: avoid_print
|
||||
print('[login] chipId=$chipId openGameID=$_openGameID');
|
||||
ApiLog.log('[login] POST $_aimeUrl');
|
||||
ApiLog.log('[login] chipId=$chipId openGameID=$_openGameID');
|
||||
|
||||
final response = await http
|
||||
.post(
|
||||
|
||||
@@ -9,6 +9,7 @@ import 'package:pointycastle/export.dart';
|
||||
|
||||
import '../config/title_server_config.dart';
|
||||
import '../models/user_data.dart';
|
||||
import 'api_log.dart';
|
||||
import '../models/user_character.dart';
|
||||
import '../models/user_kaleidx_scope.dart';
|
||||
import '../models/user_preview.dart';
|
||||
@@ -45,8 +46,21 @@ class TitleApiService {
|
||||
|
||||
TitleApiService(this._config, {this._cookies});
|
||||
|
||||
/// 从 [TitleServerConfigHolder] 构造;未配置时返回 `null`。
|
||||
///
|
||||
/// 各页原本都写 `if (!holder.isConfigured) ...; TitleApiService(holder.config!, ...)`,
|
||||
/// 收口成一个工厂,避免 `config!` 与 `isConfigured` 散落在十几个地方。
|
||||
static TitleApiService? fromHolder({String? cookies}) {
|
||||
final holder = TitleServerConfigHolder();
|
||||
if (!holder.isConfigured) return null;
|
||||
return TitleApiService(holder.config!, cookies: cookies);
|
||||
}
|
||||
|
||||
String? get cookies => _cookies;
|
||||
|
||||
/// 组装 payload 时需要同一份配置(地点 / 区域 / clientId)。
|
||||
TitleServerConfig get config => _config;
|
||||
|
||||
Uint8List _aesEncrypt(Uint8List plaintext) {
|
||||
final cipher = PaddedBlockCipherImpl(
|
||||
PKCS7Padding(),
|
||||
@@ -107,8 +121,7 @@ class TitleApiService {
|
||||
RegExp(r'JSESSIONID=([^;,]+)', caseSensitive: false).firstMatch(value);
|
||||
if (match != null) {
|
||||
_cookies = 'JSESSIONID=${match.group(1)!.trim()}';
|
||||
// ignore: avoid_print
|
||||
print('[$apiName] >>> Captured session cookie: $_cookies');
|
||||
ApiLog.log('[$apiName] >>> Captured session cookie: $_cookies');
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -135,31 +148,23 @@ class TitleApiService {
|
||||
Future<Map<String, dynamic>> _callApi(
|
||||
String apiName,
|
||||
Map<String, dynamic> packet,
|
||||
int userId,
|
||||
) async {
|
||||
int userId, {
|
||||
bool captureRaw = false,
|
||||
}) async {
|
||||
final hash = _buildHash(apiName);
|
||||
final body = _buildRequestBody(packet);
|
||||
final baseUrl = _normalizeUrl(_config.titleServerUrl);
|
||||
final url = Uri.parse('$baseUrl/$hash');
|
||||
|
||||
// ignore: avoid_print
|
||||
print('══════════════════════════════════════');
|
||||
// ignore: avoid_print
|
||||
print('[$apiName] >>> REQUEST >>>');
|
||||
// ignore: avoid_print
|
||||
print('[$apiName] URL: $url');
|
||||
// ignore: avoid_print
|
||||
print('[$apiName] userId: $userId');
|
||||
// ignore: avoid_print
|
||||
print('[$apiName] hash: $hash');
|
||||
// ignore: avoid_print
|
||||
print('[$apiName] body (encrypted): ${body.length} bytes');
|
||||
// ignore: avoid_print
|
||||
ApiLog.log('══════════════════════════════════════');
|
||||
ApiLog.log('[$apiName] >>> REQUEST >>>');
|
||||
ApiLog.log('[$apiName] URL: $url');
|
||||
ApiLog.log('[$apiName] userId: $userId');
|
||||
ApiLog.log('[$apiName] hash: $hash');
|
||||
ApiLog.log('[$apiName] body (encrypted): ${body.length} bytes');
|
||||
final packetStr = const JsonEncoder.withIndent(' ').convert(packet);
|
||||
// ignore: avoid_print
|
||||
print('[$apiName] packet (plain):');
|
||||
// ignore: avoid_print
|
||||
print(packetStr);
|
||||
ApiLog.log('[$apiName] packet (plain):');
|
||||
ApiLog.log(packetStr);
|
||||
|
||||
final uaSuffix = userId != 0 ? '$userId' : _config.clientId;
|
||||
final headers = <String, String>{
|
||||
@@ -174,8 +179,7 @@ class TitleApiService {
|
||||
final cookies = _cookies;
|
||||
if (cookies != null) {
|
||||
headers['Cookie'] = cookies;
|
||||
// ignore: avoid_print
|
||||
print('[$apiName] Cookie: $cookies');
|
||||
ApiLog.log('[$apiName] Cookie: $cookies');
|
||||
}
|
||||
|
||||
final response = await http
|
||||
@@ -188,10 +192,8 @@ class TitleApiService {
|
||||
|
||||
_captureCookiesFromResponse(response, apiName);
|
||||
|
||||
// ignore: avoid_print
|
||||
print('[$apiName] HTTP status: ${response.statusCode}');
|
||||
// ignore: avoid_print
|
||||
print('[$apiName] response bytes: ${response.bodyBytes.length}');
|
||||
ApiLog.log('[$apiName] HTTP status: ${response.statusCode}');
|
||||
ApiLog.log('[$apiName] response bytes: ${response.bodyBytes.length}');
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw TitleApiException('$apiName returned ${response.statusCode}');
|
||||
@@ -203,12 +205,10 @@ class TitleApiService {
|
||||
|
||||
final json = _processResponseBody(response.bodyBytes);
|
||||
final raw = const JsonEncoder.withIndent(' ').convert(json);
|
||||
// ignore: avoid_print
|
||||
print('[$apiName] <<< RESPONSE <<<');
|
||||
// ignore: avoid_print
|
||||
print(raw);
|
||||
// ignore: avoid_print
|
||||
print('══════════════════════════════════════');
|
||||
if (captureRaw) lastRawResponse = raw;
|
||||
ApiLog.log('[$apiName] <<< RESPONSE <<<');
|
||||
ApiLog.log(raw);
|
||||
ApiLog.log('══════════════════════════════════════');
|
||||
|
||||
return json;
|
||||
}
|
||||
@@ -243,99 +243,20 @@ class TitleApiService {
|
||||
required int userId,
|
||||
required String token,
|
||||
}) async {
|
||||
// ignore: avoid_print
|
||||
print('[getUserPreview] ===== START =====');
|
||||
// ignore: avoid_print
|
||||
print('[getUserPreview] userId=$userId');
|
||||
// ignore: avoid_print
|
||||
print('[getUserPreview] token=$token');
|
||||
// ignore: avoid_print
|
||||
print('[getUserPreview] clientId=${_config.clientId}');
|
||||
// ignore: avoid_print
|
||||
print('[getUserPreview] titleServerUrl=${_config.titleServerUrl}');
|
||||
// ignore: avoid_print
|
||||
print('[getUserPreview] aesKey.len=${_config.aesKeyBytes.length} aesIv.len=${_config.aesIvBytes.length}');
|
||||
|
||||
const apiName = 'GetUserPreviewApi';
|
||||
final hash = _buildHash(apiName);
|
||||
// ignore: avoid_print
|
||||
print('[getUserPreview] hash=$hash');
|
||||
|
||||
final packet = {
|
||||
'userId': userId,
|
||||
'segaIdAuthKey': '',
|
||||
'token': token,
|
||||
'clientId': _config.clientId,
|
||||
};
|
||||
// ignore: avoid_print
|
||||
print('[getUserPreview] packet=${jsonEncode(packet)}');
|
||||
|
||||
// ignore: avoid_print
|
||||
print('[getUserPreview] building request body (compress + encrypt)...');
|
||||
final body = _buildRequestBody(packet);
|
||||
// ignore: avoid_print
|
||||
print('[getUserPreview] body size=${body.length} bytes');
|
||||
|
||||
final baseUrl = _normalizeUrl(_config.titleServerUrl);
|
||||
final url = Uri.parse('$baseUrl/$hash');
|
||||
// ignore: avoid_print
|
||||
print('[getUserPreview] POST $url');
|
||||
|
||||
try {
|
||||
final previewHeaders = <String, String>{
|
||||
'User-Agent': '$hash#$userId',
|
||||
'Content-Type': 'application/json',
|
||||
'Mai-Encoding': _config.apiVersion,
|
||||
'Accept-Encoding': '',
|
||||
'Charset': 'UTF-8',
|
||||
'Content-Encoding': 'deflate',
|
||||
'number': '0',
|
||||
};
|
||||
final previewCookies = _cookies;
|
||||
if (previewCookies != null) {
|
||||
previewHeaders['Cookie'] = previewCookies;
|
||||
// ignore: avoid_print
|
||||
print('[getUserPreview] Cookie: $previewCookies');
|
||||
}
|
||||
|
||||
final response = await http
|
||||
.post(
|
||||
url,
|
||||
headers: previewHeaders,
|
||||
body: body,
|
||||
)
|
||||
.timeout(const Duration(seconds: 15));
|
||||
|
||||
// ignore: avoid_print
|
||||
print('[getUserPreview] HTTP status=${response.statusCode}');
|
||||
// ignore: avoid_print
|
||||
print('[getUserPreview] response body size=${response.bodyBytes.length} bytes');
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
// ignore: avoid_print
|
||||
print('[getUserPreview] non-200 response body: ${utf8.decode(response.bodyBytes.take(500).toList())}');
|
||||
throw TitleApiException(
|
||||
'Server returned ${response.statusCode}',
|
||||
);
|
||||
}
|
||||
|
||||
// ignore: avoid_print
|
||||
print('[getUserPreview] decrypting + decompressing...');
|
||||
final json = _processResponseBody(response.bodyBytes);
|
||||
final raw = const JsonEncoder.withIndent(' ').convert(json);
|
||||
lastRawResponse = raw;
|
||||
// ignore: avoid_print
|
||||
print('[getUserPreview] === RESPONSE ===');
|
||||
// ignore: avoid_print
|
||||
print(raw);
|
||||
// ignore: avoid_print
|
||||
print('[getUserPreview] ===== END =====');
|
||||
return UserPreviewDataBean.fromJson(json);
|
||||
} catch (e) {
|
||||
// ignore: avoid_print
|
||||
print('[getUserPreview] ERROR: $e');
|
||||
rethrow;
|
||||
}
|
||||
// 走通用传输层,保留 `lastRawResponse`(主页调试卡要用 GetUserPreviewApi
|
||||
// 的原文,而不是随后 UserLoginApi / GetUserDataApi 的)。
|
||||
final json = await _callApi(
|
||||
'GetUserPreviewApi',
|
||||
{
|
||||
'userId': userId,
|
||||
'segaIdAuthKey': '',
|
||||
'token': token,
|
||||
'clientId': _config.clientId,
|
||||
},
|
||||
userId,
|
||||
captureRaw: true,
|
||||
);
|
||||
return UserPreviewDataBean.fromJson(json);
|
||||
}
|
||||
|
||||
// ---- Generic data APIs ----
|
||||
|
||||
@@ -22,6 +22,23 @@ class UserAllPayloadBuilder {
|
||||
static const int minCharacterLevel = 1;
|
||||
static const int maxCharacterLevel = 999999;
|
||||
|
||||
/// 高危操作不写真实成绩,playlog 统一沿用这一条占位记录
|
||||
/// (eaquira `settings.musicData` 的默认曲 Amber Chronicle)。
|
||||
static const int placeholderMusicId = 11538;
|
||||
|
||||
/// 占位 `musicData`。解锁歌曲时用待解锁曲目覆盖 [musicId],其余字段照旧。
|
||||
static Map<String, dynamic> placeholderMusicData({int? musicId}) => {
|
||||
'musicId': musicId ?? placeholderMusicId,
|
||||
'level': 0,
|
||||
'playCount': 1,
|
||||
'achievement': 0,
|
||||
'comboStatus': 0,
|
||||
'syncStatus': 0,
|
||||
'deluxscoreMax': 0,
|
||||
'scoreRank': 0,
|
||||
'extNum1': 0,
|
||||
};
|
||||
|
||||
final TitleServerConfig config;
|
||||
|
||||
const UserAllPayloadBuilder(this.config);
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../config/strings.dart';
|
||||
|
||||
/// 全站统一的卡片外壳:无阴影、16 圆角、outline 描边。
|
||||
///
|
||||
/// 之前每个页面各写一份 `Card(elevation: 0, shape: RoundedRectangleBorder(...))`,
|
||||
/// 改一处圆角/描边要改十几个文件。
|
||||
class AppCard extends StatelessWidget {
|
||||
final Widget child;
|
||||
final EdgeInsetsGeometry padding;
|
||||
final Color? borderColor;
|
||||
final EdgeInsetsGeometry? margin;
|
||||
|
||||
const AppCard({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.padding = const EdgeInsets.all(20),
|
||||
this.borderColor,
|
||||
this.margin,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Card(
|
||||
elevation: 0,
|
||||
margin: margin,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(
|
||||
color: borderColor ??
|
||||
theme.colorScheme.outline.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
child: Padding(padding: padding, child: child),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 卡片内的标题行:图标 + 彩色标题。
|
||||
class SectionTitle extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final Color? color;
|
||||
final double iconSize;
|
||||
|
||||
const SectionTitle({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.title,
|
||||
this.color,
|
||||
this.iconSize = 18,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final tint = color ?? theme.colorScheme.primary;
|
||||
return Row(
|
||||
children: [
|
||||
Icon(icon, size: iconSize, color: tint),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: tint,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 「完成后自动退出登录并返回标题页」勾选框。
|
||||
///
|
||||
/// 六个功能页原本各抄一份 `InkWell + Checkbox + Text`,现在统一成一个卡片。
|
||||
class AutoLogoutToggle extends StatelessWidget {
|
||||
final bool value;
|
||||
final bool enabled;
|
||||
final ValueChanged<bool>? onChanged;
|
||||
final String label;
|
||||
|
||||
const AutoLogoutToggle({
|
||||
super.key,
|
||||
required this.value,
|
||||
required this.enabled,
|
||||
required this.onChanged,
|
||||
this.label = AppStrings.autoLogoutAndExit,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return AppCard(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
onTap: enabled ? () => onChanged?.call(!value) : null,
|
||||
child: Row(
|
||||
children: [
|
||||
Checkbox(
|
||||
value: value,
|
||||
onChanged:
|
||||
enabled ? (v) => onChanged?.call(v ?? false) : null,
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: enabled
|
||||
? null
|
||||
: theme.colorScheme.onSurfaceVariant
|
||||
.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 页面顶部横幅(未登录 / 冷却 / 错误)。
|
||||
///
|
||||
/// [error] 为 true 用 errorContainer,否则用 tertiaryContainer;传了 [icon]
|
||||
/// 会渲染成「图标 + 文本」,否则退化成纯文本块(保持各页原有外观)。
|
||||
class AppNotice extends StatelessWidget {
|
||||
final String text;
|
||||
final bool error;
|
||||
final IconData? icon;
|
||||
|
||||
const AppNotice(
|
||||
this.text, {
|
||||
super.key,
|
||||
this.error = false,
|
||||
this.icon,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final bg = error
|
||||
? theme.colorScheme.errorContainer
|
||||
: theme.colorScheme.tertiaryContainer.withValues(alpha: 0.5);
|
||||
final fg = error
|
||||
? theme.colorScheme.onErrorContainer
|
||||
: theme.colorScheme.onTertiaryContainer;
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: icon == null
|
||||
? Text(text, style: theme.textTheme.bodySmall?.copyWith(color: fg))
|
||||
: Row(
|
||||
children: [
|
||||
Icon(icon, size: 18, color: fg),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: fg),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 统一的 SnackBar 入口,替代各页重复的 `ScaffoldMessenger...showSnackBar`。
|
||||
extension AppSnack on BuildContext {
|
||||
void showSnack(String message) {
|
||||
ScaffoldMessenger.of(this).showSnackBar(
|
||||
SnackBar(content: Text(message)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import '../config/strings.dart';
|
||||
|
||||
/// 功能页共用的「登录后冷却」计时。
|
||||
///
|
||||
/// 六个功能页(功能票 / 数值修改 / 旅行伙伴 / 一键跑图 / 万花筒 / 解锁)原本
|
||||
/// 各自抄了一份 `Timer.periodic` + `_computeRemaining`,这里收口成一个 mixin。
|
||||
///
|
||||
/// 用法:
|
||||
/// ```dart
|
||||
/// class _FooState extends State<Foo> with CooldownMixin<Foo> {
|
||||
/// @override
|
||||
/// int? get cooldownLoginDateTime => widget.loginDateTime;
|
||||
/// }
|
||||
/// ```
|
||||
/// 页面**不要**再自己覆写 `initState` / `didUpdateWidget` / `dispose` 里与冷却
|
||||
/// 相关的逻辑;如需覆写,记得调用 `super`。
|
||||
mixin CooldownMixin<T extends StatefulWidget> on State<T> {
|
||||
Timer? _cooldownTimer;
|
||||
int _cooldownRemaining = 0;
|
||||
int? _appliedLoginDateTime;
|
||||
|
||||
/// 当前会话的 `UserLoginApi` 时间戳;`null` 表示没有有效登录态。
|
||||
int? get cooldownLoginDateTime;
|
||||
|
||||
bool get isLoggedIn => cooldownLoginDateTime != null;
|
||||
|
||||
int get cooldownRemaining => _cooldownRemaining;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_applyCooldown();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant T oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (cooldownLoginDateTime != _appliedLoginDateTime) {
|
||||
_applyCooldown();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_cooldownTimer?.cancel();
|
||||
_cooldownTimer = null;
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _applyCooldown() {
|
||||
_cooldownTimer?.cancel();
|
||||
_cooldownTimer = null;
|
||||
_appliedLoginDateTime = cooldownLoginDateTime;
|
||||
|
||||
final loginDateTime = _appliedLoginDateTime;
|
||||
if (loginDateTime == null) {
|
||||
_setRemaining(0);
|
||||
return;
|
||||
}
|
||||
|
||||
_setRemaining(_computeRemaining(loginDateTime));
|
||||
if (_cooldownRemaining <= 0) return;
|
||||
|
||||
_cooldownTimer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
if (!mounted) return;
|
||||
_setRemaining(_computeRemaining(loginDateTime));
|
||||
if (_cooldownRemaining <= 0) {
|
||||
_cooldownTimer?.cancel();
|
||||
_cooldownTimer = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _setRemaining(int value) {
|
||||
if (!mounted) {
|
||||
_cooldownRemaining = value;
|
||||
return;
|
||||
}
|
||||
setState(() => _cooldownRemaining = value);
|
||||
}
|
||||
|
||||
int _computeRemaining(int loginDateTime) {
|
||||
final nowSec = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
final remaining =
|
||||
AppStrings.ticketCooldownSeconds - (nowSec - loginDateTime);
|
||||
return remaining < 0 ? 0 : remaining;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'app_card.dart';
|
||||
|
||||
/// 高危操作共用的执行阶段。
|
||||
///
|
||||
/// 六个功能页原来各自定义 `_Step` / `TicketStep` / `MusicRiskStep`,值完全一样
|
||||
/// (功能票用 `chargeTicket` 表示「发包」,语义上就是 [upload])。
|
||||
enum RiskStep { idle, fetchData, upload, logout, complete, failed }
|
||||
|
||||
/// 执行进度卡:按顺序展示各阶段,前/当前/后三种状态。
|
||||
///
|
||||
/// 说明文字 [message] 用错误色高亮;[error] 用于额外展示底层异常文本。
|
||||
class StepProgressCard extends StatelessWidget {
|
||||
final RiskStep step;
|
||||
final String message;
|
||||
final String? error;
|
||||
|
||||
/// 要展示的 (阶段, 文案) 列表,按执行顺序给出。不含 [RiskStep.idle] / [RiskStep.failed]。
|
||||
final List<(RiskStep, String)> steps;
|
||||
|
||||
const StepProgressCard({
|
||||
super.key,
|
||||
required this.step,
|
||||
required this.steps,
|
||||
this.message = '',
|
||||
this.error,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final isFailed = step == RiskStep.failed;
|
||||
final isDone = step == RiskStep.complete;
|
||||
|
||||
return AppCard(
|
||||
borderColor: isFailed
|
||||
? theme.colorScheme.error.withValues(alpha: 0.5)
|
||||
: isDone
|
||||
? Colors.green.withValues(alpha: 0.5)
|
||||
: null,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (final (stage, label) in steps) _row(theme, stage, label),
|
||||
if (message.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
_banner(
|
||||
theme,
|
||||
message,
|
||||
background: isFailed
|
||||
? theme.colorScheme.errorContainer
|
||||
: isDone
|
||||
? Colors.green.withValues(alpha: 0.1)
|
||||
: theme.colorScheme.surfaceContainerHighest,
|
||||
foreground: isFailed
|
||||
? theme.colorScheme.onErrorContainer
|
||||
: theme.colorScheme.onSurface,
|
||||
),
|
||||
],
|
||||
if (error != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
_banner(
|
||||
theme,
|
||||
error!,
|
||||
background: theme.colorScheme.errorContainer,
|
||||
foreground: theme.colorScheme.onErrorContainer,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _banner(
|
||||
ThemeData theme,
|
||||
String text, {
|
||||
required Color background,
|
||||
required Color foreground,
|
||||
}) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: background,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
text,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
color: foreground,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _row(ThemeData theme, RiskStep stage, String label) {
|
||||
// failed 卡在发包阶段,按原逻辑回退到 upload。
|
||||
final current = step == RiskStep.failed ? RiskStep.upload : step;
|
||||
const order = [
|
||||
RiskStep.fetchData,
|
||||
RiskStep.upload,
|
||||
RiskStep.logout,
|
||||
RiskStep.complete,
|
||||
];
|
||||
final done = order.indexOf(current) > order.indexOf(stage);
|
||||
final active = step == stage;
|
||||
|
||||
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)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user