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:
Bad0RANG3 committed 2026-09-28 14:50:51 +08:00
1 parent 0808e367f7
commit 45214b11a0
21 files changed
+1196 -2176

No files matched your search

+1 -4
View File
@@ -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(() {
+16 -108
View File
@@ -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,
+4 -12
View File
@@ -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);
+49 -251
View File
@@ -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)),
],
),
);
}
}
+299
View File
@@ -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,
),
],
),
),
),
),
);
}
}
+48 -249
View File
@@ -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)),
],
),
);
}
}
+62 -313
View File
@@ -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,
+49 -250
View File
@@ -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
View File
@@ -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,
),
),
],
),
);
}
}
+53 -248
View File
@@ -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)),
],
),
);
}
}