refactor: 拆分主页与公共组件,扫码登录改走 WinHTTP
- 抽出 lib/core 的日期/JSON 工具,收敛各页重复解析 - 主页与音乐风险页拆成组件,风险功能共用一套运行框架 - 修复设置页作为主页页签保存时弹掉根路由导致的黑屏 - 登录对齐 Astral aimedb 协议:84 位二维码校验、签名与请求头修正, 补 GlobalSign 中间证书;Windows 改用 WinHTTP 绕过服务端对 BoringSSL 的 TLS 指纹拦截(证书校验保持开启) - 补开发预览数据与测试
This commit is contained in:
1 parent
0cb5320290
commit
e9357f5969
55 files changed
+4387
-3392
No files matched your search
@@ -6,6 +6,7 @@ import 'package:flutter/rendering.dart';
|
||||
import '../config/responsive.dart';
|
||||
import '../config/strings.dart';
|
||||
import '../config/title_server_config.dart';
|
||||
import '../models/session_model.dart';
|
||||
import '../models/user_rating.dart';
|
||||
import '../services/music_data_service.dart';
|
||||
import '../services/png_export_service.dart';
|
||||
@@ -56,7 +57,7 @@ class _Best50PageState extends State<Best50Page> {
|
||||
@override
|
||||
void didUpdateWidget(Best50Page oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
// 本页在 IndexedStack 里会先于 preview/登录请求被构建,cookies 到位后补一次加载。
|
||||
// 用户可能在 preview/登录请求完成前就切到本页,cookies 到位后再补一次加载。
|
||||
if (oldWidget.cookies != widget.cookies &&
|
||||
(_rating == null || _error != null)) {
|
||||
_bootstrap();
|
||||
@@ -75,7 +76,9 @@ class _Best50PageState extends State<Best50Page> {
|
||||
});
|
||||
|
||||
try {
|
||||
final service = TitleApiService.fromHolder(cookies: widget.cookies)!;
|
||||
final service = SessionModel.instance.createTitleService(
|
||||
cookies: widget.cookies,
|
||||
)!;
|
||||
final rating = await service.getUserRatingTyped(widget.userId);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
@@ -87,10 +90,8 @@ class _Best50PageState extends State<Best50Page> {
|
||||
await MusicDataService.instance.load();
|
||||
if (!mounted) return;
|
||||
setState(() => _musicReady = true);
|
||||
} on TitleApiException catch (e) {
|
||||
_fail(e.message);
|
||||
} catch (e) {
|
||||
_fail('$e');
|
||||
_fail(describeApiError(e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
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 '../widgets/risk/risk_widgets.dart';
|
||||
import 'collectibles_page.dart';
|
||||
import 'kaleidx_scope_page.dart';
|
||||
import 'map_traverse_page.dart';
|
||||
@@ -48,10 +47,19 @@ class _HighRiskFeaturePageState extends State<HighRiskFeaturePage>
|
||||
@override
|
||||
int? get cooldownLoginDateTime => widget.loginDateTime;
|
||||
|
||||
void _openUnlockMusic() {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => UnlockMusicPage(
|
||||
void _push(Widget page) {
|
||||
Navigator.of(context).push(MaterialPageRoute(builder: (_) => page));
|
||||
}
|
||||
|
||||
/// 中转页只是导航入口,七张卡片的差异只有图标、文案和目标页,
|
||||
/// 用一张表驱动,避免七份几乎相同的 `Navigator.push`。
|
||||
List<({IconData icon, String title, String desc, Widget page})> get _entries {
|
||||
return [
|
||||
(
|
||||
icon: Icons.lock_open,
|
||||
title: AppStrings.unlockFeatureTitle,
|
||||
desc: AppStrings.unlockFeatureDesc,
|
||||
page: UnlockMusicPage(
|
||||
userId: widget.userId,
|
||||
cookies: widget.cookies,
|
||||
loginDateTime: widget.loginDateTime,
|
||||
@@ -60,13 +68,11 @@ class _HighRiskFeaturePageState extends State<HighRiskFeaturePage>
|
||||
onExitToTitle: widget.onExitToTitle,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _openCollectibles() {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => CollectiblesPage(
|
||||
(
|
||||
icon: Icons.card_giftcard,
|
||||
title: AppStrings.collectiblesFeatureTitle,
|
||||
desc: AppStrings.collectiblesFeatureDesc,
|
||||
page: CollectiblesPage(
|
||||
userId: widget.userId,
|
||||
cookies: widget.cookies,
|
||||
loginDateTime: widget.loginDateTime,
|
||||
@@ -75,13 +81,11 @@ class _HighRiskFeaturePageState extends State<HighRiskFeaturePage>
|
||||
onExitToTitle: widget.onExitToTitle,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _openTravelPartner() {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => TravelPartnerPage(
|
||||
(
|
||||
icon: Icons.workspace_premium_outlined,
|
||||
title: AppStrings.travelPartnerFeatureTitle,
|
||||
desc: AppStrings.travelPartnerFeatureDesc,
|
||||
page: TravelPartnerPage(
|
||||
userId: widget.userId,
|
||||
cookies: widget.cookies,
|
||||
loginDateTime: widget.loginDateTime,
|
||||
@@ -89,28 +93,37 @@ class _HighRiskFeaturePageState extends State<HighRiskFeaturePage>
|
||||
onExitToTitle: widget.onExitToTitle,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _openNumberPatch(NumberPatchMode mode) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => NumberPatchPage(
|
||||
(
|
||||
icon: Icons.speed,
|
||||
title: AppStrings.ratingFeatureTitle,
|
||||
desc: AppStrings.ratingFeatureDesc,
|
||||
page: NumberPatchPage(
|
||||
userId: widget.userId,
|
||||
cookies: widget.cookies,
|
||||
loginDateTime: widget.loginDateTime,
|
||||
loginId: widget.loginId,
|
||||
onExitToTitle: widget.onExitToTitle,
|
||||
mode: mode,
|
||||
mode: NumberPatchMode.rating,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _openMapTraverse() {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => MapTraversePage(
|
||||
(
|
||||
icon: Icons.commute,
|
||||
title: AppStrings.maiMileFeatureTitle,
|
||||
desc: AppStrings.maiMileFeatureDesc,
|
||||
page: NumberPatchPage(
|
||||
userId: widget.userId,
|
||||
cookies: widget.cookies,
|
||||
loginDateTime: widget.loginDateTime,
|
||||
loginId: widget.loginId,
|
||||
onExitToTitle: widget.onExitToTitle,
|
||||
mode: NumberPatchMode.maiMile,
|
||||
),
|
||||
),
|
||||
(
|
||||
icon: Icons.map_outlined,
|
||||
title: AppStrings.mapFeatureTitle,
|
||||
desc: AppStrings.mapFeatureDesc,
|
||||
page: MapTraversePage(
|
||||
userId: widget.userId,
|
||||
cookies: widget.cookies,
|
||||
loginDateTime: widget.loginDateTime,
|
||||
@@ -118,13 +131,11 @@ class _HighRiskFeaturePageState extends State<HighRiskFeaturePage>
|
||||
onExitToTitle: widget.onExitToTitle,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _openKaleidxScope() {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => KaleidxScopePage(
|
||||
(
|
||||
icon: Icons.science_outlined,
|
||||
title: AppStrings.kaleidxFeatureTitle,
|
||||
desc: AppStrings.kaleidxFeatureDesc,
|
||||
page: KaleidxScopePage(
|
||||
userId: widget.userId,
|
||||
cookies: widget.cookies,
|
||||
loginDateTime: widget.loginDateTime,
|
||||
@@ -132,167 +143,70 @@ class _HighRiskFeaturePageState extends State<HighRiskFeaturePage>
|
||||
onExitToTitle: widget.onExitToTitle,
|
||||
),
|
||||
),
|
||||
);
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
if (!TitleServerConfigHolder().isConfigured) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.settings_ethernet,
|
||||
size: 48, color: theme.colorScheme.primary),
|
||||
const SizedBox(height: 16),
|
||||
Text(AppStrings.ticketNotConfigured,
|
||||
style: theme.textTheme.titleMedium),
|
||||
],
|
||||
),
|
||||
),
|
||||
return const RiskConfigMissingView(
|
||||
message: AppStrings.ticketNotConfigured,
|
||||
);
|
||||
}
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: responsiveBody(
|
||||
context,
|
||||
child: Column(
|
||||
children: [
|
||||
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(
|
||||
theme,
|
||||
icon: Icons.lock_open,
|
||||
title: AppStrings.unlockFeatureTitle,
|
||||
desc: AppStrings.unlockFeatureDesc,
|
||||
onTap: _openUnlockMusic,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildNavCard(
|
||||
theme,
|
||||
icon: Icons.card_giftcard,
|
||||
title: AppStrings.collectiblesFeatureTitle,
|
||||
desc: AppStrings.collectiblesFeatureDesc,
|
||||
onTap: _openCollectibles,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildNavCard(
|
||||
theme,
|
||||
icon: Icons.workspace_premium_outlined,
|
||||
title: AppStrings.travelPartnerFeatureTitle,
|
||||
desc: AppStrings.travelPartnerFeatureDesc,
|
||||
onTap: _openTravelPartner,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildNavCard(
|
||||
theme,
|
||||
icon: Icons.speed,
|
||||
title: AppStrings.ratingFeatureTitle,
|
||||
desc: AppStrings.ratingFeatureDesc,
|
||||
onTap: () => _openNumberPatch(NumberPatchMode.rating),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildNavCard(
|
||||
theme,
|
||||
icon: Icons.commute,
|
||||
title: AppStrings.maiMileFeatureTitle,
|
||||
desc: AppStrings.maiMileFeatureDesc,
|
||||
onTap: () => _openNumberPatch(NumberPatchMode.maiMile),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildNavCard(
|
||||
theme,
|
||||
icon: Icons.map_outlined,
|
||||
title: AppStrings.mapFeatureTitle,
|
||||
desc: AppStrings.mapFeatureDesc,
|
||||
onTap: _openMapTraverse,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildNavCard(
|
||||
theme,
|
||||
icon: Icons.science_outlined,
|
||||
title: AppStrings.kaleidxFeatureTitle,
|
||||
desc: AppStrings.kaleidxFeatureDesc,
|
||||
onTap: _openKaleidxScope,
|
||||
),
|
||||
],
|
||||
final entries = _entries;
|
||||
return RiskPageBody(
|
||||
children: [
|
||||
RiskStatusNotices(
|
||||
isLoggedIn: isLoggedIn,
|
||||
cooldownRemaining: cooldownRemaining,
|
||||
notLoggedInText: AppStrings.musicRiskHubNotLoggedIn,
|
||||
cooldownText: AppStrings.musicRiskHubCooldownNotice,
|
||||
),
|
||||
),
|
||||
const RiskFeatureCard(
|
||||
icon: Icons.warning_amber_rounded,
|
||||
title: AppStrings.musicRiskHubTitle,
|
||||
description: AppStrings.musicRiskHubDesc,
|
||||
danger: true,
|
||||
),
|
||||
for (var i = 0; i < entries.length; i++) ...[
|
||||
const SizedBox(height: 12),
|
||||
_NavCard(
|
||||
icon: entries[i].icon,
|
||||
title: entries[i].title,
|
||||
desc: entries[i].desc,
|
||||
onTap: () => _push(entries[i].page),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildDescCard(ThemeData theme) {
|
||||
class _NavCard extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String desc;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _NavCard({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.desc,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Card(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(
|
||||
color: theme.colorScheme.error.withValues(alpha: 0.4),
|
||||
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.warning_amber_rounded,
|
||||
size: 18, color: theme.colorScheme.error),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
AppStrings.musicRiskHubTitle,
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: theme.colorScheme.error,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
AppStrings.musicRiskHubDesc,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNavCard(
|
||||
ThemeData theme, {
|
||||
required IconData icon,
|
||||
required String title,
|
||||
required String desc,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
return Card(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(color: theme.colorScheme.outline.withValues(alpha: 0.3)),
|
||||
),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
onTap: onTap,
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../config/strings.dart';
|
||||
|
||||
/// 主页底部导航的六个页签,顺序即 [IndexedStack] 的下标。
|
||||
enum HomeTab {
|
||||
home(AppStrings.tabHome, Icons.home_outlined, Icons.home),
|
||||
tickets(
|
||||
AppStrings.tabTickets,
|
||||
Icons.confirmation_number_outlined,
|
||||
Icons.confirmation_number,
|
||||
),
|
||||
best50(AppStrings.tabBest50, Icons.bar_chart_outlined, Icons.bar_chart),
|
||||
risk(AppStrings.tabRisk, Icons.warning_outlined, Icons.inventory_2),
|
||||
settings(AppStrings.tabSettings, Icons.settings_outlined, Icons.settings),
|
||||
about(AppStrings.tabAbout, Icons.info_outlined, Icons.info);
|
||||
|
||||
final String title;
|
||||
final IconData icon;
|
||||
final IconData selectedIcon;
|
||||
|
||||
const HomeTab(this.title, this.icon, this.selectedIcon);
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../config/responsive.dart';
|
||||
import '../../config/strings.dart';
|
||||
import '../../config/title_server_config.dart';
|
||||
import '../../models/user_data.dart';
|
||||
import '../../models/user_preview.dart';
|
||||
import '../../widgets/account_backup_card.dart';
|
||||
import '../../widgets/app_notice.dart';
|
||||
import '../../widgets/connection_share_card.dart';
|
||||
import '../../widgets/home/home_common.dart';
|
||||
import '../../widgets/home/home_debug_card.dart';
|
||||
import '../../widgets/home/home_preview_cards.dart';
|
||||
import '../../widgets/home/home_user_data_cards.dart';
|
||||
|
||||
/// 主页第一个页签的内容:账号概要、统计卡与调试信息。
|
||||
///
|
||||
/// 数据与回调都由 [HomePage] 持有,这里只负责按加载状态切换展示。
|
||||
class HomeTabView extends StatelessWidget {
|
||||
final int userId;
|
||||
final String token;
|
||||
final String? cookies;
|
||||
final UserPreviewDataBean? preview;
|
||||
final UserDataBean? userData;
|
||||
final bool loading;
|
||||
final String? error;
|
||||
final bool isGameLoggedIn;
|
||||
final bool forcePreviewApi;
|
||||
final bool showRestoredNotice;
|
||||
final String? rawJson;
|
||||
|
||||
final VoidCallback onRetry;
|
||||
final VoidCallback onOpenSettings;
|
||||
final VoidCallback onLogin;
|
||||
|
||||
const HomeTabView({
|
||||
super.key,
|
||||
required this.userId,
|
||||
required this.token,
|
||||
required this.cookies,
|
||||
required this.preview,
|
||||
required this.userData,
|
||||
required this.loading,
|
||||
required this.error,
|
||||
required this.isGameLoggedIn,
|
||||
required this.forcePreviewApi,
|
||||
required this.showRestoredNotice,
|
||||
required this.rawJson,
|
||||
required this.onRetry,
|
||||
required this.onOpenSettings,
|
||||
required this.onLogin,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
// 开发预览等入口会直接塞入种子数据,此时没配 Title Server 也应该展示。
|
||||
if (!TitleServerConfigHolder().isConfigured &&
|
||||
userData == null &&
|
||||
preview == null) {
|
||||
return _ConfigMissingView(onOpenSettings: onOpenSettings);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (error != null) {
|
||||
return _ErrorView(theme: theme, error: error!, onRetry: onRetry);
|
||||
}
|
||||
|
||||
final data = userData;
|
||||
if (data != null) {
|
||||
return _buildFullData(context, data);
|
||||
}
|
||||
|
||||
final p = preview;
|
||||
if (p == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final showLoginButton = forcePreviewApi && !isGameLoggedIn;
|
||||
if (p.isLogin || showLoginButton) {
|
||||
return _buildPreviewOnly(
|
||||
context,
|
||||
p,
|
||||
showLoginButton: showLoginButton,
|
||||
);
|
||||
}
|
||||
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
Widget _buildPreviewOnly(
|
||||
BuildContext context,
|
||||
UserPreviewDataBean preview, {
|
||||
required bool showLoginButton,
|
||||
}) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: responsiveBody(
|
||||
context,
|
||||
child: Column(
|
||||
children: [
|
||||
if (preview.errorId != 0) ErrorIdBanner(errorId: preview.errorId),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.tertiaryContainer.withValues(
|
||||
alpha: 0.4,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Text(
|
||||
showLoginButton
|
||||
? 'Preview API 模式,点击下方按钮登录。'
|
||||
: AppStrings.inheritedAccountNotice,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onTertiaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (showLoginButton) ...[
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
onPressed: onLogin,
|
||||
icon: const Icon(Icons.login, size: 20),
|
||||
label: const Text(AppStrings.login),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
HomeProfileCard(
|
||||
userName: preview.userName,
|
||||
iconId: preview.iconId,
|
||||
playerRating: preview.playerRating,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
...responsiveGrid(
|
||||
context: context,
|
||||
children: [
|
||||
PreviewGameInfoCard(preview: preview),
|
||||
PreviewStatusCard(preview: preview),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
ConnectionShareCard(userId: userId, token: token),
|
||||
if (rawJson != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
DebugRawJsonCard(rawJson: rawJson!),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFullData(BuildContext context, UserDataBean data) {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: responsiveBody(
|
||||
context,
|
||||
child: Column(
|
||||
children: [
|
||||
if (showRestoredNotice)
|
||||
AppNotice(
|
||||
AppStrings.sessionRestoredNotice,
|
||||
icon: Icons.science_outlined,
|
||||
),
|
||||
HomeProfileCard(
|
||||
userName: data.userName,
|
||||
iconId: data.iconId,
|
||||
playerRating: data.playerRating,
|
||||
highestRating: '${data.highestRating}',
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
...responsiveGrid(
|
||||
context: context,
|
||||
children: [
|
||||
RatingBreakdownCard(data: data),
|
||||
FirstPlayCard(data: data),
|
||||
GameInfoFromUserDataCard(data: data),
|
||||
PlayStatsCard(data: data),
|
||||
StatusFromUserDataCard(preview: preview, data: data),
|
||||
UserDataDetailCard(data: data),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
ConnectionShareCard(userId: userId, token: token),
|
||||
const SizedBox(height: 12),
|
||||
AccountBackupCard(userId: userId, cookies: cookies),
|
||||
if (rawJson != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
DebugRawJsonCard(rawJson: rawJson!),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ConfigMissingView extends StatelessWidget {
|
||||
final VoidCallback onOpenSettings;
|
||||
|
||||
const _ConfigMissingView({required this.onOpenSettings});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.settings_ethernet,
|
||||
size: 64,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
AppStrings.titleServerNotConfigured,
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
AppStrings.titleServerNotConfiguredDesc,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton.icon(
|
||||
onPressed: onOpenSettings,
|
||||
icon: const Icon(Icons.settings, size: 20),
|
||||
label: const Text(AppStrings.openSettings),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ErrorView extends StatelessWidget {
|
||||
final ThemeData theme;
|
||||
final String error;
|
||||
final VoidCallback onRetry;
|
||||
|
||||
const _ErrorView({
|
||||
required this.theme,
|
||||
required this.error,
|
||||
required this.onRetry,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 48,
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(AppStrings.loadFailed, style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
error,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
FilledButton.icon(
|
||||
onPressed: onRetry,
|
||||
icon: const Icon(Icons.refresh, size: 20),
|
||||
label: const Text(AppStrings.retry),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+120
-1049
File diff suppressed because it is too large.
Load diff
@@ -1,15 +1,16 @@
|
||||
|
||||
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 '../models/user_kaleidx_scope.dart';
|
||||
import '../services/title_api_service.dart';
|
||||
import '../services/user_all_payload_builder.dart';
|
||||
import '../widgets/app_card.dart';
|
||||
import '../widgets/app_notice.dart';
|
||||
import '../widgets/cooldown_mixin.dart';
|
||||
import '../widgets/risk/risk_run_mixin.dart';
|
||||
import '../widgets/risk/risk_widgets.dart';
|
||||
import '../widgets/step_progress.dart';
|
||||
|
||||
/// 万花筒专区:发现新的宿命之门、获取门的钥匙、设置门的通关状态。
|
||||
@@ -41,7 +42,7 @@ class KaleidxScopePage extends StatefulWidget {
|
||||
enum _ClearChoice { keep, cleared, uncleared }
|
||||
|
||||
class _KaleidxScopePageState extends State<KaleidxScopePage>
|
||||
with CooldownMixin<KaleidxScopePage> {
|
||||
with CooldownMixin<KaleidxScopePage>, RiskRunMixin<KaleidxScopePage> {
|
||||
final _gateIdController = TextEditingController();
|
||||
final List<int> _pending = [];
|
||||
|
||||
@@ -52,12 +53,6 @@ class _KaleidxScopePageState extends State<KaleidxScopePage>
|
||||
Map<String, Map<String, dynamic>> _userAllData = const {};
|
||||
List<UserKaleidxScopeBean> _serverScopes = const [];
|
||||
|
||||
bool _loading = false;
|
||||
bool _running = false;
|
||||
bool _autoLogout = true;
|
||||
RiskStep _step = RiskStep.idle;
|
||||
String _stepMessage = '';
|
||||
|
||||
@override
|
||||
int? get cooldownLoginDateTime => widget.loginDateTime;
|
||||
|
||||
@@ -120,11 +115,13 @@ class _KaleidxScopePageState extends State<KaleidxScopePage>
|
||||
}
|
||||
|
||||
Future<void> _fetchData() async {
|
||||
if (!TitleServerConfigHolder().isConfigured) return;
|
||||
setState(() => _loading = true);
|
||||
final service = SessionModel.instance.createTitleService(
|
||||
cookies: widget.cookies,
|
||||
);
|
||||
if (service == null) return;
|
||||
setRiskFetching(true);
|
||||
|
||||
try {
|
||||
final service = TitleApiService.fromHolder(cookies: widget.cookies)!;
|
||||
final results = await Future.wait([
|
||||
service.fetchUserAllData(widget.userId),
|
||||
service.getUserKaleidxScopes(widget.userId),
|
||||
@@ -133,28 +130,25 @@ class _KaleidxScopePageState extends State<KaleidxScopePage>
|
||||
setState(() {
|
||||
_userAllData = results[0] as Map<String, Map<String, dynamic>>;
|
||||
_serverScopes = results[1] as List<UserKaleidxScopeBean>;
|
||||
_loading = false;
|
||||
riskFetching = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _loading = false);
|
||||
_snack('${AppStrings.loadFailed}: $e');
|
||||
setState(() => riskFetching = false);
|
||||
_snack('${AppStrings.loadFailed}: ${describeApiError(e)}');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _run() async {
|
||||
if (!TitleServerConfigHolder().isConfigured) {
|
||||
_snack(AppStrings.ticketNotConfigured);
|
||||
return;
|
||||
}
|
||||
final loginDateTime = widget.loginDateTime;
|
||||
final loginId = widget.loginId;
|
||||
if (loginDateTime == null || loginId == null) {
|
||||
_snack(AppStrings.kaleidxNotLoggedIn);
|
||||
return;
|
||||
}
|
||||
if (cooldownRemaining > 0) {
|
||||
_snack(AppStrings.kaleidxCooldownNotice(cooldownRemaining));
|
||||
final guard = riskPreconditionError(
|
||||
loginDateTime: widget.loginDateTime,
|
||||
loginId: widget.loginId,
|
||||
cooldownRemaining: cooldownRemaining,
|
||||
notLoggedInMessage: AppStrings.kaleidxNotLoggedIn,
|
||||
cooldownNotice: AppStrings.kaleidxCooldownNotice,
|
||||
);
|
||||
if (guard != null) {
|
||||
_snack(guard);
|
||||
return;
|
||||
}
|
||||
if (_pending.isEmpty) {
|
||||
@@ -166,15 +160,19 @@ class _KaleidxScopePageState extends State<KaleidxScopePage>
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _running = true);
|
||||
final loginDateTime = widget.loginDateTime!;
|
||||
final loginId = widget.loginId!;
|
||||
setRiskRunning(true);
|
||||
|
||||
try {
|
||||
final service = TitleApiService.fromHolder(cookies: widget.cookies)!;
|
||||
final service = SessionModel.instance.createTitleService(
|
||||
cookies: widget.cookies,
|
||||
)!;
|
||||
final builder = UserAllPayloadBuilder(service.config);
|
||||
|
||||
var data = _userAllData;
|
||||
if (data.isEmpty || _serverScopes.isEmpty) {
|
||||
_updateStep(RiskStep.fetchData);
|
||||
updateRiskStep(RiskStep.fetchData);
|
||||
final results = await Future.wait([
|
||||
service.fetchUserAllData(widget.userId),
|
||||
service.getUserKaleidxScopes(widget.userId),
|
||||
@@ -188,7 +186,7 @@ class _KaleidxScopePageState extends State<KaleidxScopePage>
|
||||
}
|
||||
}
|
||||
|
||||
_updateStep(RiskStep.upload);
|
||||
updateRiskStep(RiskStep.upload);
|
||||
final packet = builder.build(
|
||||
userId: widget.userId,
|
||||
loginId: loginId,
|
||||
@@ -209,20 +207,12 @@ class _KaleidxScopePageState extends State<KaleidxScopePage>
|
||||
);
|
||||
|
||||
await service.upsertUserAll(packet, widget.userId);
|
||||
_updateStep(RiskStep.complete, await _verify(service));
|
||||
|
||||
if (_autoLogout && widget.onExitToTitle != null) {
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
if (!mounted) return;
|
||||
_updateStep(RiskStep.logout, AppStrings.exitingToTitle);
|
||||
await widget.onExitToTitle!();
|
||||
}
|
||||
} on TitleApiException catch (e) {
|
||||
_updateStep(RiskStep.failed, e.message);
|
||||
updateRiskStep(RiskStep.complete, await _verify(service));
|
||||
await autoLogoutAndExit(widget.onExitToTitle);
|
||||
} catch (e) {
|
||||
_updateStep(RiskStep.failed, '$e');
|
||||
reportRiskError(e);
|
||||
} finally {
|
||||
if (mounted) setState(() => _running = false);
|
||||
setRiskRunning(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,7 +222,7 @@ class _KaleidxScopePageState extends State<KaleidxScopePage>
|
||||
try {
|
||||
rows = await service.getUserKaleidxScopes(widget.userId);
|
||||
} catch (e) {
|
||||
return AppStrings.kaleidxVerifyFailed('$e');
|
||||
return AppStrings.kaleidxVerifyFailed(describeApiError(e));
|
||||
}
|
||||
if (mounted) setState(() => _serverScopes = rows);
|
||||
|
||||
@@ -246,106 +236,73 @@ class _KaleidxScopePageState extends State<KaleidxScopePage>
|
||||
return AppStrings.kaleidxVerified(saved.length);
|
||||
}
|
||||
|
||||
void _updateStep(RiskStep step, [String? message]) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_step = step;
|
||||
_stepMessage = message ?? '';
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text(AppStrings.kaleidxFeatureTitle)),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: responsiveBody(
|
||||
context,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (!isLoggedIn)
|
||||
const AppNotice(AppStrings.kaleidxNotLoggedIn, error: true),
|
||||
if (isLoggedIn && cooldownRemaining > 0)
|
||||
AppNotice(
|
||||
AppStrings.kaleidxCooldownNotice(cooldownRemaining),
|
||||
),
|
||||
AppCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SectionTitle(
|
||||
icon: Icons.science_outlined,
|
||||
title: AppStrings.kaleidxFeatureTitle,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
AppStrings.kaleidxFeatureDesc,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_inputCard(theme),
|
||||
const SizedBox(height: 12),
|
||||
_fetchCard(theme),
|
||||
const SizedBox(height: 12),
|
||||
AutoLogoutToggle(
|
||||
value: _autoLogout,
|
||||
enabled: widget.onExitToTitle != null && !_running,
|
||||
onChanged: (v) => setState(() => _autoLogout = v),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_runButton(theme),
|
||||
if (_step != RiskStep.idle) ...[
|
||||
const SizedBox(height: 16),
|
||||
StepProgressCard(
|
||||
step: _step,
|
||||
message: _stepMessage,
|
||||
steps: [
|
||||
(RiskStep.fetchData, AppStrings.kaleidxStepFetch),
|
||||
(RiskStep.upload, AppStrings.kaleidxStepUpload),
|
||||
if (_autoLogout) (RiskStep.logout, AppStrings.stepLogout),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _checkRow({
|
||||
required String label,
|
||||
required bool value,
|
||||
required bool enabled,
|
||||
required ValueChanged<bool> onChanged,
|
||||
}) {
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
onTap: enabled ? () => onChanged(!value) : null,
|
||||
child: Row(
|
||||
body: RiskPageBody(
|
||||
children: [
|
||||
Checkbox(
|
||||
value: value,
|
||||
onChanged: enabled ? (v) => onChanged(v ?? false) : null,
|
||||
RiskStatusNotices(
|
||||
isLoggedIn: isLoggedIn,
|
||||
cooldownRemaining: cooldownRemaining,
|
||||
notLoggedInText: AppStrings.kaleidxNotLoggedIn,
|
||||
cooldownText: AppStrings.kaleidxCooldownNotice,
|
||||
),
|
||||
Expanded(child: Text(label)),
|
||||
RiskFeatureCard(
|
||||
icon: Icons.science_outlined,
|
||||
title: AppStrings.kaleidxFeatureTitle,
|
||||
description: AppStrings.kaleidxFeatureDesc,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_inputCard(theme),
|
||||
const SizedBox(height: 12),
|
||||
RiskFetchCard(
|
||||
hasData: _userAllData.isNotEmpty,
|
||||
statusText: _userAllData.isNotEmpty
|
||||
? '${AppStrings.kaleidxFetched} (${_serverScopes.length})'
|
||||
: AppStrings.kaleidxNoData,
|
||||
loadingLabel: AppStrings.travelPartnerFetching,
|
||||
actionLabel: AppStrings.kaleidxFetchData,
|
||||
loading: riskFetching,
|
||||
enabled: !riskRunning,
|
||||
onFetch: _fetchData,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
AutoLogoutToggle(
|
||||
value: riskAutoLogout,
|
||||
enabled: widget.onExitToTitle != null && !riskRunning,
|
||||
onChanged: setRiskAutoLogout,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
RiskRunButton(
|
||||
running: riskRunning,
|
||||
enabled: isLoggedIn,
|
||||
icon: Icons.lock_open_outlined,
|
||||
label: AppStrings.kaleidxRun,
|
||||
runningLabel: AppStrings.kaleidxRunning,
|
||||
onPressed: _run,
|
||||
),
|
||||
if (riskStep != RiskStep.idle) ...[
|
||||
const SizedBox(height: 16),
|
||||
StepProgressCard(
|
||||
step: riskStep,
|
||||
message: riskStepMessage,
|
||||
steps: [
|
||||
(RiskStep.fetchData, AppStrings.kaleidxStepFetch),
|
||||
(RiskStep.upload, AppStrings.kaleidxStepUpload),
|
||||
if (riskAutoLogout) (RiskStep.logout, AppStrings.stepLogout),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _inputCard(ThemeData theme) {
|
||||
final enabled = !_running;
|
||||
final enabled = !riskRunning;
|
||||
return AppCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
@@ -376,13 +333,13 @@ class _KaleidxScopePageState extends State<KaleidxScopePage>
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
_checkRow(
|
||||
RiskCheckRow(
|
||||
label: AppStrings.kaleidxActionDiscover,
|
||||
value: _discover,
|
||||
enabled: enabled,
|
||||
onChanged: (v) => setState(() => _discover = v),
|
||||
),
|
||||
_checkRow(
|
||||
RiskCheckRow(
|
||||
label: AppStrings.kaleidxActionKey,
|
||||
value: _giveKey,
|
||||
enabled: enabled,
|
||||
@@ -466,82 +423,4 @@ class _KaleidxScopePageState extends State<KaleidxScopePage>
|
||||
row.isClear,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _fetchCard(ThemeData theme) {
|
||||
final hasData = _userAllData.isNotEmpty;
|
||||
return AppCard(
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
hasData ? Icons.check_circle : Icons.download,
|
||||
size: 18,
|
||||
color: hasData ? Colors.green : theme.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
hasData
|
||||
? '${AppStrings.kaleidxFetched} (${_serverScopes.length})'
|
||||
: AppStrings.kaleidxNoData,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: hasData
|
||||
? Colors.green
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: 32,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _loading || _running ? null : _fetchData,
|
||||
icon: _loading
|
||||
? const SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.refresh, size: 16),
|
||||
label: Text(
|
||||
_loading
|
||||
? AppStrings.travelPartnerFetching
|
||||
: AppStrings.kaleidxFetchData,
|
||||
style: theme.textTheme.labelSmall,
|
||||
),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _runButton(ThemeData theme) {
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
onPressed: _running || !isLoggedIn ? null : _run,
|
||||
icon: _running
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.lock_open_outlined, size: 20),
|
||||
label: Text(
|
||||
_running ? AppStrings.kaleidxRunning : AppStrings.kaleidxRun,
|
||||
),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+20
-25
@@ -6,6 +6,7 @@ import '../models/session_model.dart';
|
||||
import '../models/session_share.dart';
|
||||
import '../services/file_picker_service.dart';
|
||||
import '../services/qr_service.dart';
|
||||
import '../services/title_api_service.dart';
|
||||
import '../widgets/app_notice.dart';
|
||||
import 'home_page.dart';
|
||||
import 'settings_page.dart';
|
||||
@@ -22,7 +23,6 @@ class LoginPage extends StatefulWidget {
|
||||
|
||||
class _LoginPageState extends State<LoginPage> {
|
||||
final _qrController = TextEditingController();
|
||||
String _qrCode = '';
|
||||
String _rawInput = '';
|
||||
SessionShare? _share;
|
||||
bool _loading = false;
|
||||
@@ -38,7 +38,6 @@ class _LoginPageState extends State<LoginPage> {
|
||||
final text = value.trim();
|
||||
setState(() {
|
||||
_rawInput = text;
|
||||
_qrCode = SessionModel.extractQRCode(text);
|
||||
_share = SessionShare.tryDecode(text);
|
||||
});
|
||||
}
|
||||
@@ -51,12 +50,9 @@ class _LoginPageState extends State<LoginPage> {
|
||||
final result = await decodeQRFromBytes(bytes);
|
||||
|
||||
if (result != null && result.isNotEmpty) {
|
||||
if (!mounted) return;
|
||||
_qrController.text = result;
|
||||
setState(() {
|
||||
_rawInput = result.trim();
|
||||
_qrCode = SessionModel.extractQRCode(result);
|
||||
_share = SessionShare.tryDecode(result);
|
||||
});
|
||||
_onQRContentChanged(result);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text(AppStrings.qrScanSuccess)),
|
||||
@@ -79,18 +75,11 @@ class _LoginPageState extends State<LoginPage> {
|
||||
}
|
||||
|
||||
Future<void> _onLogin() async {
|
||||
if (_qrCode.isEmpty) return;
|
||||
|
||||
// 令牌是粘贴内容的后 64 位,前缀可能在截取之后就没了,所以要拿原文判断;
|
||||
// 认不出来时必须给提示,否则点了按钮一声不响,像是坏了。
|
||||
if (!_rawInput.contains('SGWCMAID')) {
|
||||
context.showSnack(AppStrings.qrTokenUnrecognized);
|
||||
return;
|
||||
}
|
||||
if (_rawInput.isEmpty) return;
|
||||
setState(() => _loading = true);
|
||||
|
||||
try {
|
||||
final result = await SessionModel.instance.loginWithQr(_qrCode);
|
||||
final result = await SessionModel.instance.loginWithQr(_rawInput);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
@@ -105,18 +94,22 @@ class _LoginPageState extends State<LoginPage> {
|
||||
),
|
||||
);
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'${AppStrings.loginFailed} (errorID: ${result.errorId})',
|
||||
),
|
||||
),
|
||||
context.showSnack(
|
||||
'${AppStrings.loginFailed}:'
|
||||
'${result.message ?? 'errorID=${result.errorId}'}',
|
||||
);
|
||||
}
|
||||
} on FormatException catch (e) {
|
||||
// 令牌格式不对 / 没配 Keychip:登录请求根本没发出去,要说清楚是哪一种。
|
||||
if (mounted) {
|
||||
context.showSnack('${AppStrings.qrTokenUnrecognized}:${e.message}');
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('${AppStrings.requestFailed}: $e')),
|
||||
SnackBar(
|
||||
content: Text('${AppStrings.requestFailed}: ${describeApiError(e)}'),
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
@@ -155,7 +148,9 @@ class _LoginPageState extends State<LoginPage> {
|
||||
);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
context.showSnack('${AppStrings.sessionRestoreFailed}: $e');
|
||||
context.showSnack(
|
||||
'${AppStrings.sessionRestoreFailed}: ${describeApiError(e)}',
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
@@ -285,7 +280,7 @@ class _LoginPageState extends State<LoginPage> {
|
||||
child: FilledButton.icon(
|
||||
onPressed: isRestore
|
||||
? (_loading ? null : _onRestoreSession)
|
||||
: (_qrCode.isNotEmpty && !_loading)
|
||||
: (_rawInput.isNotEmpty && !_loading)
|
||||
? _onLogin
|
||||
: null,
|
||||
icon: _loading
|
||||
|
||||
+101
-196
@@ -1,14 +1,16 @@
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../config/responsive.dart';
|
||||
import '../config/strings.dart';
|
||||
import '../config/title_server_config.dart';
|
||||
import '../core/json_value.dart';
|
||||
import '../models/session_model.dart';
|
||||
import '../services/title_api_service.dart';
|
||||
import '../services/user_all_payload_builder.dart';
|
||||
import '../widgets/app_card.dart';
|
||||
import '../widgets/app_notice.dart';
|
||||
import '../widgets/cooldown_mixin.dart';
|
||||
import '../widgets/risk/risk_run_mixin.dart';
|
||||
import '../widgets/risk/risk_widgets.dart';
|
||||
import '../widgets/step_progress.dart';
|
||||
|
||||
/// 一键跑图:把选中的区域标记为已完成。
|
||||
@@ -43,7 +45,7 @@ class MapTraversePage extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _MapTraversePageState extends State<MapTraversePage>
|
||||
with CooldownMixin<MapTraversePage> {
|
||||
with CooldownMixin<MapTraversePage>, RiskRunMixin<MapTraversePage> {
|
||||
final _mapIdController = TextEditingController();
|
||||
final List<int> _pending = [];
|
||||
|
||||
@@ -51,12 +53,6 @@ class _MapTraversePageState extends State<MapTraversePage>
|
||||
Set<int>? _serverMapIds;
|
||||
List<Map<String, dynamic>> _serverMaps = const [];
|
||||
|
||||
bool _loading = false;
|
||||
bool _running = false;
|
||||
bool _autoLogout = true;
|
||||
RiskStep _step = RiskStep.idle;
|
||||
String _stepMessage = '';
|
||||
|
||||
@override
|
||||
int? get cooldownLoginDateTime => widget.loginDateTime;
|
||||
|
||||
@@ -87,17 +83,19 @@ class _MapTraversePageState extends State<MapTraversePage>
|
||||
void _applyServerMaps(List<Map<String, dynamic>> rows) {
|
||||
_serverMaps = rows;
|
||||
_serverMapIds = rows
|
||||
.map((e) => (e['mapId'] as num?)?.toInt() ?? 0)
|
||||
.map((e) => jsonInt(e['mapId']))
|
||||
.where((e) => e != 0)
|
||||
.toSet();
|
||||
}
|
||||
|
||||
Future<void> _fetchData() async {
|
||||
if (!TitleServerConfigHolder().isConfigured) return;
|
||||
setState(() => _loading = true);
|
||||
final service = SessionModel.instance.createTitleService(
|
||||
cookies: widget.cookies,
|
||||
);
|
||||
if (service == null) return;
|
||||
setRiskFetching(true);
|
||||
|
||||
try {
|
||||
final service = TitleApiService.fromHolder(cookies: widget.cookies)!;
|
||||
final results = await Future.wait([
|
||||
service.fetchUserAllData(widget.userId),
|
||||
service.getUserMaps(widget.userId),
|
||||
@@ -106,28 +104,25 @@ class _MapTraversePageState extends State<MapTraversePage>
|
||||
setState(() {
|
||||
_userAllData = results[0] as Map<String, Map<String, dynamic>>;
|
||||
_applyServerMaps(results[1] as List<Map<String, dynamic>>);
|
||||
_loading = false;
|
||||
riskFetching = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _loading = false);
|
||||
_snack('${AppStrings.loadFailed}: $e');
|
||||
setState(() => riskFetching = false);
|
||||
_snack('${AppStrings.loadFailed}: ${describeApiError(e)}');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _run() async {
|
||||
if (!TitleServerConfigHolder().isConfigured) {
|
||||
_snack(AppStrings.ticketNotConfigured);
|
||||
return;
|
||||
}
|
||||
final loginDateTime = widget.loginDateTime;
|
||||
final loginId = widget.loginId;
|
||||
if (loginDateTime == null || loginId == null) {
|
||||
_snack(AppStrings.mapNotLoggedIn);
|
||||
return;
|
||||
}
|
||||
if (cooldownRemaining > 0) {
|
||||
_snack(AppStrings.mapCooldownNotice(cooldownRemaining));
|
||||
final guard = riskPreconditionError(
|
||||
loginDateTime: widget.loginDateTime,
|
||||
loginId: widget.loginId,
|
||||
cooldownRemaining: cooldownRemaining,
|
||||
notLoggedInMessage: AppStrings.mapNotLoggedIn,
|
||||
cooldownNotice: AppStrings.mapCooldownNotice,
|
||||
);
|
||||
if (guard != null) {
|
||||
_snack(guard);
|
||||
return;
|
||||
}
|
||||
if (_pending.isEmpty) {
|
||||
@@ -135,16 +130,20 @@ class _MapTraversePageState extends State<MapTraversePage>
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _running = true);
|
||||
final loginDateTime = widget.loginDateTime!;
|
||||
final loginId = widget.loginId!;
|
||||
setRiskRunning(true);
|
||||
|
||||
try {
|
||||
final service = TitleApiService.fromHolder(cookies: widget.cookies)!;
|
||||
final service = SessionModel.instance.createTitleService(
|
||||
cookies: widget.cookies,
|
||||
)!;
|
||||
final builder = UserAllPayloadBuilder(service.config);
|
||||
|
||||
var data = _userAllData;
|
||||
var serverIds = _serverMapIds;
|
||||
if (data.isEmpty || serverIds == null) {
|
||||
_updateStep(RiskStep.fetchData);
|
||||
updateRiskStep(RiskStep.fetchData);
|
||||
final results = await Future.wait([
|
||||
service.fetchUserAllData(widget.userId),
|
||||
service.getUserMaps(widget.userId),
|
||||
@@ -155,7 +154,7 @@ class _MapTraversePageState extends State<MapTraversePage>
|
||||
serverIds = _serverMapIds!;
|
||||
}
|
||||
|
||||
_updateStep(RiskStep.upload);
|
||||
updateRiskStep(RiskStep.upload);
|
||||
final packet = builder.build(
|
||||
userId: widget.userId,
|
||||
loginId: loginId,
|
||||
@@ -175,20 +174,12 @@ class _MapTraversePageState extends State<MapTraversePage>
|
||||
builder.applyMapPatch(packet, maps: maps, newFlags: flags);
|
||||
|
||||
await service.upsertUserAll(packet, widget.userId);
|
||||
_updateStep(RiskStep.complete, await _verify(service));
|
||||
|
||||
if (_autoLogout && widget.onExitToTitle != null) {
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
if (!mounted) return;
|
||||
_updateStep(RiskStep.logout, AppStrings.exitingToTitle);
|
||||
await widget.onExitToTitle!();
|
||||
}
|
||||
} on TitleApiException catch (e) {
|
||||
_updateStep(RiskStep.failed, e.message);
|
||||
updateRiskStep(RiskStep.complete, await _verify(service));
|
||||
await autoLogoutAndExit(widget.onExitToTitle);
|
||||
} catch (e) {
|
||||
_updateStep(RiskStep.failed, '$e');
|
||||
reportRiskError(e);
|
||||
} finally {
|
||||
if (mounted) setState(() => _running = false);
|
||||
setRiskRunning(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,7 +190,7 @@ class _MapTraversePageState extends State<MapTraversePage>
|
||||
try {
|
||||
rows = await service.getUserMaps(widget.userId);
|
||||
} catch (e) {
|
||||
return AppStrings.mapVerifyFailed('$e');
|
||||
return AppStrings.mapVerifyFailed(describeApiError(e));
|
||||
}
|
||||
if (mounted) setState(() => _applyServerMaps(rows));
|
||||
|
||||
@@ -211,91 +202,83 @@ class _MapTraversePageState extends State<MapTraversePage>
|
||||
return AppStrings.mapVerified(requested.length);
|
||||
}
|
||||
|
||||
void _updateStep(RiskStep step, [String? message]) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_step = step;
|
||||
_stepMessage = message ?? '';
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text(AppStrings.mapFeatureTitle)),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: responsiveBody(
|
||||
context,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (!isLoggedIn)
|
||||
const AppNotice(AppStrings.mapNotLoggedIn, error: true),
|
||||
if (isLoggedIn && cooldownRemaining > 0)
|
||||
AppNotice(AppStrings.mapCooldownNotice(cooldownRemaining)),
|
||||
AppCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SectionTitle(
|
||||
icon: Icons.map_outlined,
|
||||
title: AppStrings.mapFeatureTitle,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
AppStrings.mapFeatureDesc,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
AppStrings.mapCollectiblesNotice,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.error,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_inputCard(theme),
|
||||
const SizedBox(height: 12),
|
||||
_fetchCard(theme),
|
||||
const SizedBox(height: 12),
|
||||
AutoLogoutToggle(
|
||||
value: _autoLogout,
|
||||
enabled: widget.onExitToTitle != null && !_running,
|
||||
onChanged: (v) => setState(() => _autoLogout = v),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_runButton(theme),
|
||||
if (_step != RiskStep.idle) ...[
|
||||
const SizedBox(height: 16),
|
||||
StepProgressCard(
|
||||
step: _step,
|
||||
message: _stepMessage,
|
||||
steps: [
|
||||
(RiskStep.fetchData, AppStrings.mapStepFetch),
|
||||
(RiskStep.upload, AppStrings.mapStepUpload),
|
||||
if (_autoLogout) (RiskStep.logout, AppStrings.stepLogout),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
body: RiskPageBody(
|
||||
children: [
|
||||
RiskStatusNotices(
|
||||
isLoggedIn: isLoggedIn,
|
||||
cooldownRemaining: cooldownRemaining,
|
||||
notLoggedInText: AppStrings.mapNotLoggedIn,
|
||||
cooldownText: AppStrings.mapCooldownNotice,
|
||||
),
|
||||
),
|
||||
RiskFeatureCard(
|
||||
icon: Icons.map_outlined,
|
||||
title: AppStrings.mapFeatureTitle,
|
||||
description: AppStrings.mapFeatureDesc,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 10),
|
||||
child: Text(
|
||||
AppStrings.mapCollectiblesNotice,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.error,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_inputCard(theme),
|
||||
const SizedBox(height: 12),
|
||||
RiskFetchCard(
|
||||
hasData: _serverMapIds != null,
|
||||
statusText: _serverMapIds != null
|
||||
? '${AppStrings.mapFetched} (${_serverMaps.length})'
|
||||
: AppStrings.mapNoData,
|
||||
loadingLabel: AppStrings.travelPartnerFetching,
|
||||
actionLabel: AppStrings.mapFetchData,
|
||||
loading: riskFetching,
|
||||
enabled: !riskRunning,
|
||||
onFetch: _fetchData,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
AutoLogoutToggle(
|
||||
value: riskAutoLogout,
|
||||
enabled: widget.onExitToTitle != null && !riskRunning,
|
||||
onChanged: setRiskAutoLogout,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
RiskRunButton(
|
||||
running: riskRunning,
|
||||
enabled: isLoggedIn,
|
||||
icon: Icons.flag_outlined,
|
||||
label: AppStrings.mapRun,
|
||||
runningLabel: AppStrings.mapRunning,
|
||||
onPressed: _run,
|
||||
),
|
||||
if (riskStep != RiskStep.idle) ...[
|
||||
const SizedBox(height: 16),
|
||||
StepProgressCard(
|
||||
step: riskStep,
|
||||
message: riskStepMessage,
|
||||
steps: [
|
||||
(RiskStep.fetchData, AppStrings.mapStepFetch),
|
||||
(RiskStep.upload, AppStrings.mapStepUpload),
|
||||
if (riskAutoLogout) (RiskStep.logout, AppStrings.stepLogout),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _inputCard(ThemeData theme) {
|
||||
final enabled = !_running;
|
||||
final enabled = !riskRunning;
|
||||
return AppCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
@@ -341,82 +324,4 @@ class _MapTraversePageState extends State<MapTraversePage>
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _fetchCard(ThemeData theme) {
|
||||
final hasData = _serverMapIds != null;
|
||||
return AppCard(
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
hasData ? Icons.check_circle : Icons.download,
|
||||
size: 18,
|
||||
color: hasData ? Colors.green : theme.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
hasData
|
||||
? '${AppStrings.mapFetched} (${_serverMaps.length})'
|
||||
: AppStrings.mapNoData,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: hasData
|
||||
? Colors.green
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: 32,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _loading || _running ? null : _fetchData,
|
||||
icon: _loading
|
||||
? const SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.refresh, size: 16),
|
||||
label: Text(
|
||||
_loading
|
||||
? AppStrings.travelPartnerFetching
|
||||
: AppStrings.mapFetchData,
|
||||
style: theme.textTheme.labelSmall,
|
||||
),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _runButton(ThemeData theme) {
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
onPressed: _running || !isLoggedIn ? null : _run,
|
||||
icon: _running
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.flag_outlined, size: 20),
|
||||
label: Text(
|
||||
_running ? AppStrings.mapRunning : AppStrings.mapRun,
|
||||
),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,17 @@
|
||||
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/title_api_service.dart';
|
||||
import '../services/user_all_payload_builder.dart';
|
||||
import '../widgets/app_card.dart';
|
||||
import '../widgets/app_notice.dart';
|
||||
import '../widgets/cooldown_mixin.dart';
|
||||
import '../widgets/risk/music_risk_editors.dart';
|
||||
import '../widgets/risk/risk_list_widgets.dart';
|
||||
import '../widgets/risk/risk_run_mixin.dart';
|
||||
import '../widgets/risk/risk_widgets.dart';
|
||||
import '../widgets/step_progress.dart';
|
||||
|
||||
/// Dart port of `eaquira/action/UnlockMusic.py`:
|
||||
@@ -25,39 +29,6 @@ enum MusicRiskFeatureMode {
|
||||
collectibles,
|
||||
}
|
||||
|
||||
/// 一行待提交的收藏品道具(对应 wire 上的 UserItem)。
|
||||
class _PendingItem {
|
||||
final int kind;
|
||||
final int id;
|
||||
|
||||
const _PendingItem(this.kind, this.id);
|
||||
}
|
||||
|
||||
/// 一行待提交的歌曲解锁,展开成 itemKind 5/6/7 的若干行。
|
||||
class _PendingMusic {
|
||||
final int musicId;
|
||||
final bool base;
|
||||
final bool master;
|
||||
final bool remaster;
|
||||
|
||||
const _PendingMusic(this.musicId, this.base, this.master, this.remaster);
|
||||
|
||||
bool get hasAnyOption => base || master || remaster;
|
||||
|
||||
/// 难度由 itemKind 表达,不写在 musicDetail 上。
|
||||
List<int> get itemKinds => [
|
||||
if (base) 5,
|
||||
if (master) 6,
|
||||
if (remaster) 7,
|
||||
];
|
||||
|
||||
String get optionLabel => [
|
||||
if (base) '歌曲',
|
||||
if (master) 'Master',
|
||||
if (remaster) 'Re:Master',
|
||||
].join('/');
|
||||
}
|
||||
|
||||
class MusicRiskFeatureView extends StatefulWidget {
|
||||
final int userId;
|
||||
final String? cookies;
|
||||
@@ -96,106 +67,42 @@ class MusicRiskFeatureView extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _MusicRiskFeatureViewState extends State<MusicRiskFeatureView>
|
||||
with CooldownMixin<MusicRiskFeatureView> {
|
||||
// ── 解锁选项 (UnlockMusic) ──
|
||||
final _unlockMusicIdController = TextEditingController();
|
||||
bool _unlockMusic = false;
|
||||
bool _unlockMaster = false;
|
||||
bool _unlockRemaster = false;
|
||||
|
||||
// ── 收藏品选项 (Collectibles) ──
|
||||
final _itemIdController = TextEditingController();
|
||||
int _itemKind = 3;
|
||||
|
||||
with CooldownMixin<MusicRiskFeatureView>, RiskRunMixin<MusicRiskFeatureView> {
|
||||
// ── 待提交列表:userItemList 可以一次带多行 ──
|
||||
// 真客户端的 ExportUserItems() 就是把 Plate/Title/Icon/Partner/Frame/Ticket 与
|
||||
// MusicUnlock/Master/Remaster 各列表拼成多行 UserItem,BuildListData 再只发增量、
|
||||
// isNewItemList 一行一个字符。所以多行同类/异类混发是客户端自己的做法。
|
||||
final List<_PendingItem> _pendingItems = [];
|
||||
final List<_PendingMusic> _pendingMusics = [];
|
||||
final List<PendingItem> _pendingItems = [];
|
||||
final List<PendingMusic> _pendingMusics = [];
|
||||
|
||||
// ── 运行状态 ──
|
||||
Map<String, Map<String, dynamic>>? _userAllData;
|
||||
bool _fetching = false;
|
||||
String? _fetchError;
|
||||
bool _running = false;
|
||||
bool _autoLogout = true;
|
||||
RiskStep _step = RiskStep.idle;
|
||||
String _stepMessage = '';
|
||||
String? _error;
|
||||
|
||||
bool get _isUnlock => widget.mode == MusicRiskFeatureMode.unlockMusic;
|
||||
|
||||
/// ConstParameter.DxScoreBaseID=10000 / StrongScoreBaseID=20000
|
||||
/// (`MAI2System/ConstParameter.cs:168,171`)。这段里的歌 Master 需要
|
||||
/// 显式 itemKind 6,不像 id < 10000 那样在 IsUnlockMaster 里直接放过。
|
||||
bool _isDeluxeRange(_PendingMusic music) =>
|
||||
music.musicId >= 10000 && music.musicId < 20000;
|
||||
|
||||
@override
|
||||
int? get cooldownLoginDateTime => widget.loginDateTime;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_unlockMusicIdController.dispose();
|
||||
_itemIdController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _updateStep(RiskStep step, [String? message]) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_step = step;
|
||||
_stepMessage = message ?? '';
|
||||
});
|
||||
}
|
||||
|
||||
int _unlockMusicId() =>
|
||||
int.tryParse(_unlockMusicIdController.text.trim()) ?? -1;
|
||||
|
||||
int _itemId() => int.tryParse(_itemIdController.text.trim()) ?? -1;
|
||||
|
||||
void _snack(String message) => context.showSnack(message);
|
||||
|
||||
/// 把当前输入框里的收藏品加入待提交列表。
|
||||
void _addItemToList() {
|
||||
final id = _itemId();
|
||||
if (id <= 0) {
|
||||
_snack(AppStrings.collectiblesNeedItemId);
|
||||
return;
|
||||
}
|
||||
if (_pendingItems.any((e) => e.kind == _itemKind && e.id == id)) {
|
||||
/// 加入待提交收藏品;重复时提示并保留输入。
|
||||
bool _addItem(PendingItem item) {
|
||||
if (_pendingItems.any((e) => e.kind == item.kind && e.id == item.id)) {
|
||||
_snack(AppStrings.listDuplicate);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
setState(() {
|
||||
_pendingItems.add(_PendingItem(_itemKind, id));
|
||||
_itemIdController.clear();
|
||||
});
|
||||
setState(() => _pendingItems.add(item));
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 把当前输入框里的歌曲+勾选的难度加入待解锁列表。
|
||||
void _addMusicToList() {
|
||||
final id = _unlockMusicId();
|
||||
if (id <= 0) {
|
||||
_snack(AppStrings.unlockNeedMusicId);
|
||||
return;
|
||||
}
|
||||
if (!_unlockMusic && !_unlockMaster && !_unlockRemaster) {
|
||||
_snack(AppStrings.unlockNeedOption);
|
||||
return;
|
||||
}
|
||||
if (_pendingMusics.any((e) => e.musicId == id)) {
|
||||
/// 加入待解锁歌曲;重复时提示并保留输入。
|
||||
bool _addMusic(PendingMusic music) {
|
||||
if (_pendingMusics.any((e) => e.musicId == music.musicId)) {
|
||||
_snack(AppStrings.listDuplicate);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
setState(() {
|
||||
_pendingMusics.add(
|
||||
_PendingMusic(id, _unlockMusic, _unlockMaster, _unlockRemaster),
|
||||
);
|
||||
_unlockMusicIdController.clear();
|
||||
_unlockMusic = _unlockMaster = _unlockRemaster = false;
|
||||
});
|
||||
setState(() => _pendingMusics.add(music));
|
||||
return true;
|
||||
}
|
||||
|
||||
/// eaquira UnlockMusic.py 使用 settings 中的默认 musicData。
|
||||
@@ -244,27 +151,29 @@ class _MusicRiskFeatureViewState extends State<MusicRiskFeatureView>
|
||||
}
|
||||
|
||||
Future<void> _fetchData() async {
|
||||
if (!TitleServerConfigHolder().isConfigured) return;
|
||||
final service = SessionModel.instance.createTitleService(
|
||||
cookies: widget.cookies,
|
||||
);
|
||||
if (service == null) return;
|
||||
|
||||
setState(() {
|
||||
_fetching = true;
|
||||
riskFetching = true;
|
||||
_fetchError = null;
|
||||
_userAllData = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final service = TitleApiService.fromHolder(cookies: widget.cookies)!;
|
||||
final data = await service.fetchUserAllData(widget.userId);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_userAllData = data;
|
||||
_fetching = false;
|
||||
riskFetching = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_fetchError = e.toString();
|
||||
_fetching = false;
|
||||
_fetchError = describeApiError(e);
|
||||
riskFetching = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -275,37 +184,36 @@ class _MusicRiskFeatureViewState extends State<MusicRiskFeatureView>
|
||||
_snack(validationError);
|
||||
return;
|
||||
}
|
||||
if (!TitleServerConfigHolder().isConfigured) {
|
||||
_snack(AppStrings.ticketNotConfigured);
|
||||
return;
|
||||
}
|
||||
final loginDateTime = widget.loginDateTime;
|
||||
final loginId = widget.loginId;
|
||||
if (loginDateTime == null || loginId == null) {
|
||||
_snack(_notLoggedInMessage);
|
||||
return;
|
||||
}
|
||||
if (cooldownRemaining > 0) {
|
||||
_snack(_cooldownNotice(cooldownRemaining));
|
||||
final guard = riskPreconditionError(
|
||||
loginDateTime: widget.loginDateTime,
|
||||
loginId: widget.loginId,
|
||||
cooldownRemaining: cooldownRemaining,
|
||||
notLoggedInMessage: _notLoggedInMessage,
|
||||
cooldownNotice: _cooldownNotice,
|
||||
);
|
||||
if (guard != null) {
|
||||
_snack(guard);
|
||||
return;
|
||||
}
|
||||
|
||||
final service = TitleApiService.fromHolder(cookies: widget.cookies)!;
|
||||
final loginDateTime = widget.loginDateTime!;
|
||||
final loginId = widget.loginId!;
|
||||
final service = SessionModel.instance.createTitleService(
|
||||
cookies: widget.cookies,
|
||||
)!;
|
||||
|
||||
setState(() {
|
||||
_running = true;
|
||||
_error = null;
|
||||
});
|
||||
setRiskRunning(true);
|
||||
clearRiskError();
|
||||
|
||||
try {
|
||||
Map<String, Map<String, dynamic>> data = _userAllData ?? const {};
|
||||
if (data.isEmpty) {
|
||||
_updateStep(RiskStep.fetchData);
|
||||
updateRiskStep(RiskStep.fetchData);
|
||||
data = await service.fetchUserAllData(widget.userId);
|
||||
if (mounted) setState(() => _userAllData = data);
|
||||
}
|
||||
|
||||
_updateStep(RiskStep.upload);
|
||||
updateRiskStep(RiskStep.upload);
|
||||
final musicData = _buildMusicData();
|
||||
final builder = UserAllPayloadBuilder(service.config);
|
||||
final packet = builder.build(
|
||||
@@ -351,28 +259,19 @@ class _MusicRiskFeatureViewState extends State<MusicRiskFeatureView>
|
||||
|
||||
await service.upsertUserAll(packet, widget.userId);
|
||||
|
||||
_updateStep(
|
||||
updateRiskStep(
|
||||
RiskStep.complete,
|
||||
_isUnlock
|
||||
? await _verifyUnlock(service, items)
|
||||
: AppStrings.collectiblesSuccess,
|
||||
);
|
||||
|
||||
if (_autoLogout && widget.onExitToTitle != null) {
|
||||
// 让成功状态先显示一下,再交给 HomePage 结算、退登并 pop 回主标题。
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
if (!mounted) return;
|
||||
_updateStep(RiskStep.logout, AppStrings.exitingToTitle);
|
||||
await widget.onExitToTitle!();
|
||||
}
|
||||
} on TitleApiException catch (e) {
|
||||
_updateStep(RiskStep.failed, e.message);
|
||||
setState(() => _error = e.message);
|
||||
// 让成功状态先显示一下,再交给 HomePage 结算、退登并 pop 回主标题。
|
||||
await autoLogoutAndExit(widget.onExitToTitle);
|
||||
} catch (e) {
|
||||
_updateStep(RiskStep.failed, e.toString());
|
||||
setState(() => _error = e.toString());
|
||||
reportRiskError(e);
|
||||
} finally {
|
||||
if (mounted) setState(() => _running = false);
|
||||
setRiskRunning(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -394,7 +293,7 @@ class _MusicRiskFeatureViewState extends State<MusicRiskFeatureView>
|
||||
items.map((e) => e['itemKind'] as int),
|
||||
);
|
||||
} catch (e) {
|
||||
return AppStrings.unlockVerifyFailed('$e');
|
||||
return AppStrings.unlockVerifyFailed(describeApiError(e));
|
||||
}
|
||||
|
||||
final missing = requested.where((k) => !saved.contains(k)).toList();
|
||||
@@ -414,545 +313,142 @@ class _MusicRiskFeatureViewState extends State<MusicRiskFeatureView>
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
if (!TitleServerConfigHolder().isConfigured) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.settings_ethernet,
|
||||
size: 48, color: theme.colorScheme.primary),
|
||||
const SizedBox(height: 16),
|
||||
Text(AppStrings.ticketNotConfigured,
|
||||
style: theme.textTheme.titleMedium),
|
||||
],
|
||||
),
|
||||
),
|
||||
return const RiskConfigMissingView(
|
||||
message: AppStrings.ticketNotConfigured,
|
||||
);
|
||||
}
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: responsiveBody(
|
||||
context,
|
||||
child: Column(
|
||||
children: [
|
||||
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)
|
||||
_buildUnlockCard(theme)
|
||||
else
|
||||
_buildCollectiblesCard(theme),
|
||||
const SizedBox(height: 12),
|
||||
_buildFetchCard(theme),
|
||||
const SizedBox(height: 12),
|
||||
AutoLogoutToggle(
|
||||
value: _autoLogout,
|
||||
enabled: widget.onExitToTitle != null && !_running,
|
||||
onChanged: (v) => setState(() => _autoLogout = v),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildRunButton(theme),
|
||||
if (_step != RiskStep.idle) ...[
|
||||
const SizedBox(height: 16),
|
||||
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),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDescCard(ThemeData theme) {
|
||||
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: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
_isUnlock ? Icons.lock_open : Icons.card_giftcard,
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
widget.featureDesc,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildUnlockCard(ThemeData theme) {
|
||||
final enabled = !_running;
|
||||
|
||||
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: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextField(
|
||||
controller: _unlockMusicIdController,
|
||||
enabled: enabled,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: InputDecoration(
|
||||
labelText: AppStrings.unlockMusicIdLabel,
|
||||
hintText: AppStrings.unlockMusicIdHint,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
contentPadding: const EdgeInsets.all(14),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
_buildUnlockCheckbox(
|
||||
theme,
|
||||
value: _unlockMusic,
|
||||
label: AppStrings.unlockMusicOption,
|
||||
enabled: enabled,
|
||||
onChanged: (v) => setState(() => _unlockMusic = v),
|
||||
),
|
||||
_buildUnlockCheckbox(
|
||||
theme,
|
||||
value: _unlockMaster,
|
||||
label: AppStrings.unlockMasterOption,
|
||||
enabled: enabled,
|
||||
onChanged: (v) => setState(() => _unlockMaster = v),
|
||||
),
|
||||
_buildUnlockCheckbox(
|
||||
theme,
|
||||
value: _unlockRemaster,
|
||||
label: AppStrings.unlockRemasterOption,
|
||||
enabled: enabled,
|
||||
onChanged: (v) => setState(() => _unlockRemaster = v),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildAddButton(theme, onPressed: enabled ? _addMusicToList : null),
|
||||
_buildPendingList(
|
||||
theme,
|
||||
title: AppStrings.unlockPendingTitle,
|
||||
emptyHint: AppStrings.unlockPendingEmpty,
|
||||
count: _pendingMusics.length,
|
||||
rows: [
|
||||
for (var i = 0; i < _pendingMusics.length; i++)
|
||||
_buildPendingRow(
|
||||
theme,
|
||||
leading: '#${_pendingMusics[i].musicId}',
|
||||
trailing: _pendingMusics[i].optionLabel,
|
||||
onRemove: () => setState(() => _pendingMusics.removeAt(i)),
|
||||
),
|
||||
],
|
||||
),
|
||||
// DX 段的 Master / Re:Master 不白送,勾选项里漏一个就开不出来,
|
||||
// 这是这段 ID 最容易踩的坑,只在列表真含 DX 曲时才说明。
|
||||
if (_pendingMusics.any(_isDeluxeRange)) ...[
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
AppStrings.unlockDxRangeNotice,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.error,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAddButton(ThemeData theme, {VoidCallback? onPressed}) {
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: onPressed,
|
||||
icon: const Icon(Icons.add, size: 18),
|
||||
label: const Text(AppStrings.listAddButton),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPendingList(
|
||||
ThemeData theme, {
|
||||
required String title,
|
||||
required String emptyHint,
|
||||
required int count,
|
||||
required List<Widget> rows,
|
||||
}) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
return RiskPageBody(
|
||||
children: [
|
||||
const SizedBox(height: 14),
|
||||
Text(
|
||||
'$title ($count)',
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
RiskStatusNotices(
|
||||
isLoggedIn: isLoggedIn,
|
||||
cooldownRemaining: cooldownRemaining,
|
||||
notLoggedInText: _notLoggedInMessage,
|
||||
cooldownText: _cooldownNotice,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
if (count == 0)
|
||||
Text(
|
||||
emptyHint,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
RiskFeatureCard(
|
||||
icon: _isUnlock ? Icons.lock_open : Icons.card_giftcard,
|
||||
title: widget.featureTitle,
|
||||
description: widget.featureDesc,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (_isUnlock)
|
||||
UnlockMusicEditorCard(
|
||||
enabled: !riskRunning,
|
||||
onAdd: _addMusic,
|
||||
onError: _snack,
|
||||
pendingList: _buildUnlockPendingList(),
|
||||
showDxNotice: _pendingMusics.any((m) => m.isDeluxeRange),
|
||||
)
|
||||
else
|
||||
...rows,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPendingRow(
|
||||
ThemeData theme, {
|
||||
required String leading,
|
||||
required String trailing,
|
||||
required VoidCallback onRemove,
|
||||
}) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'$leading $trailing',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
CollectiblesEditorCard(
|
||||
enabled: !riskRunning,
|
||||
onAdd: _addItem,
|
||||
onError: _snack,
|
||||
pendingList: _buildCollectiblesPendingList(),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildFetchCard(),
|
||||
const SizedBox(height: 12),
|
||||
AutoLogoutToggle(
|
||||
value: riskAutoLogout,
|
||||
enabled: widget.onExitToTitle != null && !riskRunning,
|
||||
onChanged: setRiskAutoLogout,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, size: 18),
|
||||
tooltip: AppStrings.listRemoveTooltip,
|
||||
onPressed: _running ? null : onRemove,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildRunButton(),
|
||||
if (riskStep != RiskStep.idle) ...[
|
||||
const SizedBox(height: 16),
|
||||
StepProgressCard(
|
||||
step: riskStep,
|
||||
message: riskStepMessage,
|
||||
error: riskError,
|
||||
steps: [
|
||||
(RiskStep.fetchData, AppStrings.unlockStepFetch),
|
||||
(
|
||||
RiskStep.upload,
|
||||
_isUnlock
|
||||
? AppStrings.unlockStepUpload
|
||||
: AppStrings.collectiblesStepUpload,
|
||||
),
|
||||
if (riskAutoLogout) (RiskStep.logout, AppStrings.stepLogout),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildUnlockCheckbox(
|
||||
ThemeData theme, {
|
||||
required bool value,
|
||||
required String label,
|
||||
required bool enabled,
|
||||
required ValueChanged<bool> onChanged,
|
||||
}) {
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
onTap: enabled ? () => onChanged(!value) : null,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
Checkbox(
|
||||
value: value,
|
||||
onChanged: enabled ? (v) => onChanged(v ?? false) : null,
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: enabled
|
||||
? null
|
||||
: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Widget _buildUnlockPendingList() {
|
||||
return RiskPendingList(
|
||||
title: AppStrings.unlockPendingTitle,
|
||||
emptyHint: AppStrings.unlockPendingEmpty,
|
||||
count: _pendingMusics.length,
|
||||
rows: [
|
||||
for (var i = 0; i < _pendingMusics.length; i++)
|
||||
RiskPendingRow(
|
||||
leading: '#${_pendingMusics[i].musicId}',
|
||||
trailing: _pendingMusics[i].optionLabel,
|
||||
enabled: !riskRunning,
|
||||
onRemove: () => setState(() => _pendingMusics.removeAt(i)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCollectiblesCard(ThemeData theme) {
|
||||
final enabled = !_running;
|
||||
|
||||
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: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildDropdown(
|
||||
value: _itemKind,
|
||||
label: AppStrings.collectiblesItemKindLabel,
|
||||
items: [
|
||||
for (final kind in AppStrings.collectiblesItemKinds)
|
||||
DropdownMenuItem(
|
||||
value: kind,
|
||||
child: Text(
|
||||
'${AppStrings.collectiblesItemKindName(kind)} ($kind)',
|
||||
),
|
||||
),
|
||||
],
|
||||
onChanged: (v) => setState(() => _itemKind = v),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _itemIdController,
|
||||
enabled: enabled,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: InputDecoration(
|
||||
labelText: AppStrings.collectiblesItemIdLabel,
|
||||
hintText: AppStrings.collectiblesItemIdHint,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
contentPadding: const EdgeInsets.all(14),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildAddButton(theme, onPressed: enabled ? _addItemToList : null),
|
||||
_buildPendingList(
|
||||
theme,
|
||||
title: AppStrings.collectiblesPendingTitle,
|
||||
emptyHint: AppStrings.collectiblesPendingEmpty,
|
||||
count: _pendingItems.length,
|
||||
rows: [
|
||||
for (var i = 0; i < _pendingItems.length; i++)
|
||||
_buildPendingRow(
|
||||
theme,
|
||||
leading:
|
||||
'${AppStrings.collectiblesItemKindName(_pendingItems[i].kind)} (${_pendingItems[i].kind})',
|
||||
trailing: '#${_pendingItems[i].id}',
|
||||
onRemove: () => setState(() => _pendingItems.removeAt(i)),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Widget _buildCollectiblesPendingList() {
|
||||
return RiskPendingList(
|
||||
title: AppStrings.collectiblesPendingTitle,
|
||||
emptyHint: AppStrings.collectiblesPendingEmpty,
|
||||
count: _pendingItems.length,
|
||||
rows: [
|
||||
for (var i = 0; i < _pendingItems.length; i++)
|
||||
RiskPendingRow(
|
||||
leading:
|
||||
'${AppStrings.collectiblesItemKindName(_pendingItems[i].kind)} (${_pendingItems[i].kind})',
|
||||
trailing: '#${_pendingItems[i].id}',
|
||||
enabled: !riskRunning,
|
||||
onRemove: () => setState(() => _pendingItems.removeAt(i)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFetchCard(ThemeData theme) {
|
||||
Widget _buildFetchCard() {
|
||||
final hasData = _userAllData != null;
|
||||
|
||||
return Card(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(
|
||||
color: hasData
|
||||
? 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: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
hasData ? Icons.check_circle : Icons.download,
|
||||
size: 18,
|
||||
color: hasData ? Colors.green : theme.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
hasData
|
||||
? AppStrings.unlockFetched
|
||||
: AppStrings.unlockNoData,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: hasData
|
||||
? Colors.green
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: 32,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _fetching || _running ? null : _fetchData,
|
||||
icon: _fetching
|
||||
? const SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.refresh, size: 16),
|
||||
label: Text(
|
||||
_fetching
|
||||
? AppStrings.unlockFetching
|
||||
: hasData
|
||||
? AppStrings.unlockRefetch
|
||||
: AppStrings.unlockFetchData,
|
||||
style: theme.textTheme.labelSmall,
|
||||
),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_fetchError != 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(
|
||||
_fetchError!,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onErrorContainer,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
return RiskFetchCard(
|
||||
hasData: hasData,
|
||||
statusText:
|
||||
hasData ? AppStrings.unlockFetched : AppStrings.unlockNoData,
|
||||
loadingLabel: AppStrings.unlockFetching,
|
||||
actionLabel:
|
||||
hasData ? AppStrings.unlockRefetch : AppStrings.unlockFetchData,
|
||||
error: _fetchError,
|
||||
loading: riskFetching,
|
||||
enabled: !riskRunning,
|
||||
onFetch: _fetchData,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRunButton(ThemeData theme) {
|
||||
Widget _buildRunButton() {
|
||||
final onCooldown = cooldownRemaining > 0;
|
||||
final canRun = widget.loginDateTime != null &&
|
||||
widget.loginId != null &&
|
||||
!_running &&
|
||||
!onCooldown;
|
||||
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
onPressed: canRun ? _run : null,
|
||||
icon: _running
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
|
||||
)
|
||||
: Icon(
|
||||
onCooldown
|
||||
? Icons.timer_outlined
|
||||
: (_isUnlock ? Icons.lock_open : Icons.card_giftcard),
|
||||
size: 20,
|
||||
),
|
||||
label: Text(
|
||||
_running
|
||||
? AppStrings.unlockRunning
|
||||
: onCooldown
|
||||
? AppStrings.ticketCooldownCountdown(cooldownRemaining)
|
||||
: _isUnlock
|
||||
? AppStrings.unlockMusicRun
|
||||
: AppStrings.collectiblesRun,
|
||||
),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDropdown({
|
||||
required int value,
|
||||
required String label,
|
||||
required List<DropdownMenuItem<int>> items,
|
||||
required ValueChanged<int> onChanged,
|
||||
}) {
|
||||
return DropdownButtonFormField<int>(
|
||||
initialValue: value,
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
contentPadding: const EdgeInsets.all(14),
|
||||
),
|
||||
items: items,
|
||||
onChanged: _running
|
||||
? null
|
||||
: (v) {
|
||||
if (v == null) return;
|
||||
onChanged(v);
|
||||
},
|
||||
return RiskRunButton(
|
||||
running: riskRunning,
|
||||
enabled: canRun,
|
||||
icon: onCooldown
|
||||
? Icons.timer_outlined
|
||||
: (_isUnlock ? Icons.lock_open : Icons.card_giftcard),
|
||||
label: _isUnlock ? AppStrings.unlockMusicRun : AppStrings.collectiblesRun,
|
||||
runningLabel: AppStrings.unlockRunning,
|
||||
disabledLabel:
|
||||
onCooldown ? AppStrings.ticketCooldownCountdown(cooldownRemaining) : null,
|
||||
onPressed: _run,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,16 @@
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../config/responsive.dart';
|
||||
import '../config/strings.dart';
|
||||
import '../config/title_server_config.dart';
|
||||
import '../core/json_value.dart';
|
||||
import '../models/session_model.dart';
|
||||
import '../services/title_api_service.dart';
|
||||
import '../services/user_all_payload_builder.dart';
|
||||
import '../widgets/app_card.dart';
|
||||
import '../widgets/app_notice.dart';
|
||||
import '../widgets/cooldown_mixin.dart';
|
||||
import '../widgets/risk/risk_run_mixin.dart';
|
||||
import '../widgets/risk/risk_widgets.dart';
|
||||
import '../widgets/step_progress.dart';
|
||||
|
||||
/// 数值修改:Rating 与舞里程。两者的流程一模一样(拉数据 → 改一个数 →
|
||||
@@ -98,18 +100,12 @@ class NumberPatchView extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _NumberPatchViewState extends State<NumberPatchView>
|
||||
with CooldownMixin<NumberPatchView> {
|
||||
with CooldownMixin<NumberPatchView>, RiskRunMixin<NumberPatchView> {
|
||||
final _valueController = TextEditingController();
|
||||
|
||||
Map<String, Map<String, dynamic>> _userAllData = const {};
|
||||
bool _maiMileAdd = true;
|
||||
|
||||
bool _loading = false;
|
||||
bool _running = false;
|
||||
bool _autoLogout = true;
|
||||
RiskStep _step = RiskStep.idle;
|
||||
String _stepMessage = '';
|
||||
|
||||
bool get _isRating => widget.mode == NumberPatchMode.rating;
|
||||
|
||||
@override
|
||||
@@ -123,18 +119,11 @@ class _NumberPatchViewState extends State<NumberPatchView>
|
||||
|
||||
void _snack(String message) => context.showSnack(message);
|
||||
|
||||
static int _toInt(dynamic v) {
|
||||
if (v is int) return v;
|
||||
if (v is num) return v.toInt();
|
||||
if (v is String) return int.tryParse(v) ?? 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// 从已拉取的 GetUserDataApi 里读当前值,供「累加」和界面上的当前值展示。
|
||||
int _currentOf(Map<String, Map<String, dynamic>> data, String field) {
|
||||
final userData = data['GetUserDataApi']?['userData'];
|
||||
if (userData is! Map) return 0;
|
||||
return _toInt(userData[field]);
|
||||
return jsonInt(userData[field]);
|
||||
}
|
||||
|
||||
int get _currentRating => _currentOf(_userAllData, 'playerRating');
|
||||
@@ -172,37 +161,36 @@ class _NumberPatchViewState extends State<NumberPatchView>
|
||||
}
|
||||
|
||||
Future<void> _fetchData() async {
|
||||
if (!TitleServerConfigHolder().isConfigured) return;
|
||||
setState(() => _loading = true);
|
||||
final service = SessionModel.instance.createTitleService(
|
||||
cookies: widget.cookies,
|
||||
);
|
||||
if (service == null) return;
|
||||
setRiskFetching(true);
|
||||
|
||||
try {
|
||||
final service = TitleApiService.fromHolder(cookies: widget.cookies)!;
|
||||
final data = await service.fetchUserAllData(widget.userId);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_userAllData = data;
|
||||
_loading = false;
|
||||
riskFetching = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _loading = false);
|
||||
_snack('${AppStrings.loadFailed}: $e');
|
||||
setState(() => riskFetching = false);
|
||||
_snack('${AppStrings.loadFailed}: ${describeApiError(e)}');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _run() async {
|
||||
if (!TitleServerConfigHolder().isConfigured) {
|
||||
_snack(AppStrings.ticketNotConfigured);
|
||||
return;
|
||||
}
|
||||
final loginDateTime = widget.loginDateTime;
|
||||
final loginId = widget.loginId;
|
||||
if (loginDateTime == null || loginId == null) {
|
||||
_snack(AppStrings.numberPatchNotLoggedIn);
|
||||
return;
|
||||
}
|
||||
if (cooldownRemaining > 0) {
|
||||
_snack(AppStrings.numberPatchCooldownNotice(cooldownRemaining));
|
||||
final guard = riskPreconditionError(
|
||||
loginDateTime: widget.loginDateTime,
|
||||
loginId: widget.loginId,
|
||||
cooldownRemaining: cooldownRemaining,
|
||||
notLoggedInMessage: AppStrings.numberPatchNotLoggedIn,
|
||||
cooldownNotice: AppStrings.numberPatchCooldownNotice,
|
||||
);
|
||||
if (guard != null) {
|
||||
_snack(guard);
|
||||
return;
|
||||
}
|
||||
final inputError = _checkInput();
|
||||
@@ -211,21 +199,26 @@ class _NumberPatchViewState extends State<NumberPatchView>
|
||||
return;
|
||||
}
|
||||
final input = int.parse(_valueController.text.trim());
|
||||
final loginDateTime = widget.loginDateTime!;
|
||||
final loginId = widget.loginId!;
|
||||
|
||||
setState(() => _running = true);
|
||||
setRiskRunning(true);
|
||||
clearRiskError();
|
||||
|
||||
try {
|
||||
final service = TitleApiService.fromHolder(cookies: widget.cookies)!;
|
||||
final service = SessionModel.instance.createTitleService(
|
||||
cookies: widget.cookies,
|
||||
)!;
|
||||
final builder = UserAllPayloadBuilder(service.config);
|
||||
|
||||
var data = _userAllData;
|
||||
if (data.isEmpty) {
|
||||
_updateStep(RiskStep.fetchData);
|
||||
updateRiskStep(RiskStep.fetchData);
|
||||
data = await service.fetchUserAllData(widget.userId);
|
||||
if (mounted) setState(() => _userAllData = data);
|
||||
}
|
||||
|
||||
_updateStep(RiskStep.upload);
|
||||
updateRiskStep(RiskStep.upload);
|
||||
final packet = builder.build(
|
||||
userId: widget.userId,
|
||||
loginId: loginId,
|
||||
@@ -237,8 +230,7 @@ class _NumberPatchViewState extends State<NumberPatchView>
|
||||
final String resultMessage;
|
||||
if (_isRating) {
|
||||
builder.applyRatingPatch(packet, rating: input);
|
||||
resultMessage =
|
||||
'${AppStrings.ratingSuccess} → ${_clamp(input)}';
|
||||
resultMessage = '${AppStrings.ratingSuccess} → ${_clamp(input)}';
|
||||
} else {
|
||||
final current = _currentOf(data, 'point');
|
||||
final total = _currentOf(data, 'totalPoint');
|
||||
@@ -253,103 +245,70 @@ class _NumberPatchViewState extends State<NumberPatchView>
|
||||
}
|
||||
|
||||
await service.upsertUserAll(packet, widget.userId);
|
||||
_updateStep(RiskStep.complete, resultMessage);
|
||||
|
||||
if (_autoLogout && widget.onExitToTitle != null) {
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
if (!mounted) return;
|
||||
_updateStep(RiskStep.logout, AppStrings.exitingToTitle);
|
||||
await widget.onExitToTitle!();
|
||||
}
|
||||
} on TitleApiException catch (e) {
|
||||
_updateStep(RiskStep.failed, e.message);
|
||||
updateRiskStep(RiskStep.complete, resultMessage);
|
||||
await autoLogoutAndExit(widget.onExitToTitle);
|
||||
} catch (e) {
|
||||
_updateStep(RiskStep.failed, '$e');
|
||||
reportRiskError(e);
|
||||
} finally {
|
||||
if (mounted) setState(() => _running = false);
|
||||
setRiskRunning(false);
|
||||
}
|
||||
}
|
||||
|
||||
void _updateStep(RiskStep step, [String? message]) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_step = step;
|
||||
_stepMessage = message ?? '';
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: responsiveBody(
|
||||
context,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (!isLoggedIn)
|
||||
const AppNotice(AppStrings.numberPatchNotLoggedIn, error: true),
|
||||
if (isLoggedIn && cooldownRemaining > 0)
|
||||
AppNotice(
|
||||
AppStrings.numberPatchCooldownNotice(cooldownRemaining),
|
||||
),
|
||||
_descCard(theme),
|
||||
const SizedBox(height: 12),
|
||||
_inputCard(theme),
|
||||
const SizedBox(height: 12),
|
||||
_fetchCard(theme),
|
||||
const SizedBox(height: 12),
|
||||
AutoLogoutToggle(
|
||||
value: _autoLogout,
|
||||
enabled: widget.onExitToTitle != null && !_running,
|
||||
onChanged: (v) => setState(() => _autoLogout = v),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_runButton(theme),
|
||||
if (_step != RiskStep.idle) ...[
|
||||
const SizedBox(height: 16),
|
||||
StepProgressCard(
|
||||
step: _step,
|
||||
message: _stepMessage,
|
||||
steps: [
|
||||
(RiskStep.fetchData, AppStrings.unlockStepFetch),
|
||||
(RiskStep.upload, AppStrings.numberPatchStepUpload),
|
||||
if (_autoLogout) (RiskStep.logout, AppStrings.stepLogout),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
return RiskPageBody(
|
||||
children: [
|
||||
RiskStatusNotices(
|
||||
isLoggedIn: isLoggedIn,
|
||||
cooldownRemaining: cooldownRemaining,
|
||||
notLoggedInText: AppStrings.numberPatchNotLoggedIn,
|
||||
cooldownText: AppStrings.numberPatchCooldownNotice,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _descCard(ThemeData theme) {
|
||||
return AppCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SectionTitle(
|
||||
icon: _isRating ? Icons.speed : Icons.commute,
|
||||
title: widget.featureTitle,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
widget.featureDesc,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
height: 1.5,
|
||||
),
|
||||
RiskFeatureCard(
|
||||
icon: _isRating ? Icons.speed : Icons.commute,
|
||||
title: widget.featureTitle,
|
||||
description: widget.featureDesc,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_inputCard(theme),
|
||||
const SizedBox(height: 12),
|
||||
_fetchCard(),
|
||||
const SizedBox(height: 12),
|
||||
AutoLogoutToggle(
|
||||
value: riskAutoLogout,
|
||||
enabled: widget.onExitToTitle != null && !riskRunning,
|
||||
onChanged: setRiskAutoLogout,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
RiskRunButton(
|
||||
running: riskRunning,
|
||||
enabled: isLoggedIn,
|
||||
icon: Icons.send,
|
||||
label: AppStrings.numberPatchRun,
|
||||
runningLabel: AppStrings.unlockRunning,
|
||||
onPressed: _run,
|
||||
),
|
||||
if (riskStep != RiskStep.idle) ...[
|
||||
const SizedBox(height: 16),
|
||||
StepProgressCard(
|
||||
step: riskStep,
|
||||
message: riskStepMessage,
|
||||
error: riskError,
|
||||
steps: [
|
||||
(RiskStep.fetchData, AppStrings.unlockStepFetch),
|
||||
(RiskStep.upload, AppStrings.numberPatchStepUpload),
|
||||
if (riskAutoLogout) (RiskStep.logout, AppStrings.stepLogout),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _inputCard(ThemeData theme) {
|
||||
final enabled = !_running;
|
||||
final enabled = !riskRunning;
|
||||
final hasInput = int.tryParse(_valueController.text.trim()) != null;
|
||||
|
||||
return AppCard(
|
||||
@@ -430,89 +389,22 @@ class _NumberPatchViewState extends State<NumberPatchView>
|
||||
return AppStrings.numberPatchPreview(label, current, _previewTarget);
|
||||
}
|
||||
|
||||
Widget _fetchCard(ThemeData theme) {
|
||||
Widget _fetchCard() {
|
||||
final hasData = _userAllData.isNotEmpty;
|
||||
final current = _isRating ? _currentRating : _currentPoint;
|
||||
final label = _isRating
|
||||
? AppStrings.ratingCurrentValue
|
||||
: AppStrings.maiMileCurrentBalance;
|
||||
|
||||
return AppCard(
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
hasData ? Icons.check_circle : Icons.download,
|
||||
size: 18,
|
||||
color: hasData ? Colors.green : theme.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
hasData
|
||||
? '$label: $current'
|
||||
: AppStrings.unlockNoData,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
color: hasData
|
||||
? Colors.green
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: 32,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _loading || _running ? null : _fetchData,
|
||||
icon: _loading
|
||||
? const SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.refresh, size: 16),
|
||||
label: Text(
|
||||
_loading
|
||||
? AppStrings.unlockFetching
|
||||
: hasData
|
||||
? AppStrings.unlockRefetch
|
||||
: AppStrings.unlockFetchData,
|
||||
style: theme.textTheme.labelSmall,
|
||||
),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _runButton(ThemeData theme) {
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
onPressed: _running || !isLoggedIn ? null : _run,
|
||||
icon: _running
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.send, size: 20),
|
||||
label: Text(
|
||||
_running ? AppStrings.unlockRunning : AppStrings.numberPatchRun,
|
||||
),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
return RiskFetchCard(
|
||||
hasData: hasData,
|
||||
statusText: hasData ? '$label: $current' : AppStrings.unlockNoData,
|
||||
loadingLabel: AppStrings.unlockFetching,
|
||||
actionLabel:
|
||||
hasData ? AppStrings.unlockRefetch : AppStrings.unlockFetchData,
|
||||
loading: riskFetching,
|
||||
enabled: !riskRunning,
|
||||
onFetch: _fetchData,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -27,8 +27,6 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
final _regionNameController = TextEditingController();
|
||||
final _placeNameController = TextEditingController();
|
||||
final _keychipIdController = TextEditingController();
|
||||
final _aimeUrlController = TextEditingController();
|
||||
final _aimeSaltController = TextEditingController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -50,8 +48,6 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
_regionNameController.text = config.regionName;
|
||||
_placeNameController.text = config.placeName;
|
||||
_keychipIdController.text = config.keychipId;
|
||||
_aimeUrlController.text = config.aimeUrl;
|
||||
_aimeSaltController.text = config.aimeSalt;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,8 +64,6 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
_regionNameController.dispose();
|
||||
_placeNameController.dispose();
|
||||
_keychipIdController.dispose();
|
||||
_aimeUrlController.dispose();
|
||||
_aimeSaltController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -88,11 +82,19 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
regionName: _regionNameController.text.trim(),
|
||||
placeName: _placeNameController.text.trim(),
|
||||
keychipId: _keychipIdController.text.trim(),
|
||||
aimeUrl: _aimeUrlController.text.trim(),
|
||||
aimeSalt: _aimeSaltController.text.trim(),
|
||||
));
|
||||
|
||||
if (mounted) Navigator.of(context).pop();
|
||||
if (!mounted) return;
|
||||
final navigator = Navigator.of(context);
|
||||
// 作为主页页签嵌入时(showAppBar: false)没有可弹出的路由,
|
||||
// 直接 pop 会把根路由弹掉,整个导航栈变空只剩黑屏。
|
||||
if (navigator.canPop()) {
|
||||
navigator.pop();
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text(AppStrings.configSaved)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -139,11 +141,11 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
]),
|
||||
const SizedBox(height: 24),
|
||||
_buildSection(theme, AppStrings.authServerSettings, [
|
||||
_buildField(controller: _keychipIdController, label: AppStrings.labelKeychipId),
|
||||
const SizedBox(height: 14),
|
||||
_buildField(controller: _aimeUrlController, label: AppStrings.labelAimeUrl),
|
||||
const SizedBox(height: 14),
|
||||
_buildField(controller: _aimeSaltController, label: AppStrings.labelAimeSalt),
|
||||
_buildField(
|
||||
controller: _keychipIdController,
|
||||
label: AppStrings.labelKeychipId,
|
||||
help: AppStrings.helpKeychipId,
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 32),
|
||||
FilledButton.icon(
|
||||
@@ -216,11 +218,14 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
Widget _buildField({
|
||||
required TextEditingController controller,
|
||||
required String label,
|
||||
String? help,
|
||||
}) {
|
||||
return TextFormField(
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
helperText: help,
|
||||
helperStyle: const TextStyle(fontSize: 12),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
|
||||
+83
-132
@@ -1,12 +1,14 @@
|
||||
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/title_api_service.dart';
|
||||
import '../widgets/app_card.dart';
|
||||
import '../widgets/app_notice.dart';
|
||||
import '../widgets/cooldown_mixin.dart';
|
||||
import '../widgets/risk/risk_run_mixin.dart';
|
||||
import '../widgets/risk/risk_widgets.dart';
|
||||
import '../widgets/step_progress.dart';
|
||||
|
||||
class TicketPage extends StatefulWidget {
|
||||
@@ -35,19 +37,13 @@ class TicketPage extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _TicketPageState extends State<TicketPage>
|
||||
with CooldownMixin<TicketPage> {
|
||||
with CooldownMixin<TicketPage>, RiskRunMixin<TicketPage> {
|
||||
static const _ticketNameMap = {
|
||||
2: '2倍票',
|
||||
4: '4倍票',
|
||||
5: '5倍票'
|
||||
};
|
||||
|
||||
RiskStep _step = RiskStep.idle;
|
||||
String _stepMessage = '';
|
||||
String? _error;
|
||||
bool _running = false;
|
||||
bool _autoLogout = true;
|
||||
|
||||
List<Map<String, dynamic>>? _tickets;
|
||||
bool _ticketsLoading = false;
|
||||
String? _ticketsError;
|
||||
@@ -70,16 +66,11 @@ class _TicketPageState extends State<TicketPage>
|
||||
return null;
|
||||
}
|
||||
|
||||
void _updateStep(RiskStep step, [String? message]) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_step = step;
|
||||
_stepMessage = message ?? '';
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _loadTickets() async {
|
||||
if (!TitleServerConfigHolder().isConfigured) return;
|
||||
final service = SessionModel.instance.createTitleService(
|
||||
cookies: widget.cookies,
|
||||
);
|
||||
if (service == null) return;
|
||||
|
||||
setState(() {
|
||||
_ticketsLoading = true;
|
||||
@@ -89,7 +80,6 @@ class _TicketPageState extends State<TicketPage>
|
||||
});
|
||||
|
||||
try {
|
||||
final service = TitleApiService.fromHolder(cookies: widget.cookies)!;
|
||||
final data = await service.getUserCharge(widget.userId);
|
||||
if (!mounted) return;
|
||||
|
||||
@@ -101,73 +91,67 @@ class _TicketPageState extends State<TicketPage>
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_ticketsError = e.toString();
|
||||
_ticketsError = describeApiError(e);
|
||||
_ticketsLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _runTicket() async {
|
||||
if (_selectedTicketId == null) {
|
||||
final ticketId = _selectedTicketId;
|
||||
if (ticketId == null) {
|
||||
_snack(AppStrings.ticketNotSelected);
|
||||
return;
|
||||
}
|
||||
if (!TitleServerConfigHolder().isConfigured) {
|
||||
_snack(AppStrings.ticketNotConfigured);
|
||||
return;
|
||||
}
|
||||
final loginDateTime = widget.loginDateTime;
|
||||
if (loginDateTime == null) {
|
||||
_snack(AppStrings.ticketNotLoggedIn);
|
||||
return;
|
||||
}
|
||||
if (cooldownRemaining > 0) {
|
||||
_snack(AppStrings.ticketCooldownNotice(cooldownRemaining));
|
||||
final guard = riskPreconditionError(
|
||||
loginDateTime: widget.loginDateTime,
|
||||
loginId: null,
|
||||
cooldownRemaining: cooldownRemaining,
|
||||
notLoggedInMessage: AppStrings.ticketNotLoggedIn,
|
||||
cooldownNotice: AppStrings.ticketCooldownNotice,
|
||||
requireLoginId: false,
|
||||
);
|
||||
if (guard != null) {
|
||||
_snack(guard);
|
||||
return;
|
||||
}
|
||||
|
||||
final selectedTicket = _findTicket(_selectedTicketId!);
|
||||
final selectedTicket = _findTicket(ticketId);
|
||||
if (selectedTicket != null &&
|
||||
(selectedTicket['stock'] as num?)?.toInt() != 0) {
|
||||
_snack(AppStrings.ticketStockNotEmpty);
|
||||
return;
|
||||
}
|
||||
|
||||
final service = TitleApiService.fromHolder(cookies: widget.cookies)!;
|
||||
final ticketId = _selectedTicketId!;
|
||||
|
||||
setState(() {
|
||||
_running = true;
|
||||
_error = null;
|
||||
});
|
||||
final service = SessionModel.instance.createTitleService(
|
||||
cookies: widget.cookies,
|
||||
)!;
|
||||
setRiskRunning(true);
|
||||
clearRiskError();
|
||||
|
||||
try {
|
||||
_updateStep(RiskStep.upload);
|
||||
updateRiskStep(RiskStep.upload);
|
||||
await service.upsertUserChargeLog(
|
||||
userId: widget.userId,
|
||||
ticketId: ticketId,
|
||||
loginDateTime: loginDateTime,
|
||||
loginDateTime: widget.loginDateTime!,
|
||||
playerRating: widget.playerRating,
|
||||
);
|
||||
|
||||
if (_autoLogout && widget.onLogoutRequested != null) {
|
||||
_updateStep(RiskStep.logout);
|
||||
await Future.delayed(const Duration(seconds: 5));
|
||||
if (riskAutoLogout && widget.onLogoutRequested != null) {
|
||||
updateRiskStep(RiskStep.logout);
|
||||
await Future<void>.delayed(const Duration(seconds: 5));
|
||||
await widget.onLogoutRequested!();
|
||||
}
|
||||
|
||||
_updateStep(
|
||||
updateRiskStep(
|
||||
RiskStep.complete,
|
||||
'${AppStrings.ticketUsed}: ${_ticketName(ticketId)}',
|
||||
);
|
||||
} on TitleApiException catch (e) {
|
||||
_updateStep(RiskStep.failed, e.message);
|
||||
setState(() => _error = e.message);
|
||||
} catch (e) {
|
||||
_updateStep(RiskStep.failed, e.toString());
|
||||
setState(() => _error = e.toString());
|
||||
reportRiskError(e);
|
||||
} finally {
|
||||
if (mounted) setState(() => _running = false);
|
||||
setRiskRunning(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,63 +160,46 @@ class _TicketPageState extends State<TicketPage>
|
||||
final theme = Theme.of(context);
|
||||
|
||||
if (!TitleServerConfigHolder().isConfigured) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.settings_ethernet, size: 48, color: theme.colorScheme.primary),
|
||||
const SizedBox(height: 16),
|
||||
Text(AppStrings.ticketNotConfigured, style: theme.textTheme.titleMedium),
|
||||
],
|
||||
),
|
||||
),
|
||||
return const RiskConfigMissingView(
|
||||
message: AppStrings.ticketNotConfigured,
|
||||
);
|
||||
}
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: responsiveBody(
|
||||
context,
|
||||
child: Column(
|
||||
children: [
|
||||
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),
|
||||
AutoLogoutToggle(
|
||||
value: _autoLogout,
|
||||
enabled: widget.onLogoutRequested != null && !_running,
|
||||
onChanged: (v) => setState(() => _autoLogout = v),
|
||||
label: AppStrings.ticketAutoLogout,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildRunButton(theme),
|
||||
if (_step != RiskStep.idle) ...[
|
||||
const SizedBox(height: 16),
|
||||
StepProgressCard(
|
||||
step: _step,
|
||||
message: _stepMessage,
|
||||
error: _error,
|
||||
steps: [
|
||||
(RiskStep.upload, AppStrings.stepChargeTicket),
|
||||
if (_autoLogout) (RiskStep.logout, AppStrings.stepLogout),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
return _buildBody(theme);
|
||||
}
|
||||
|
||||
Widget _buildBody(ThemeData theme) {
|
||||
return RiskPageBody(
|
||||
children: [
|
||||
RiskStatusNotices(
|
||||
isLoggedIn: isLoggedIn,
|
||||
cooldownRemaining: cooldownRemaining,
|
||||
notLoggedInText: AppStrings.ticketNotLoggedIn,
|
||||
cooldownText: AppStrings.ticketCooldownNotice,
|
||||
),
|
||||
),
|
||||
_buildTicketListCard(theme),
|
||||
const SizedBox(height: 16),
|
||||
AutoLogoutToggle(
|
||||
value: riskAutoLogout,
|
||||
enabled: widget.onLogoutRequested != null && !riskRunning,
|
||||
onChanged: setRiskAutoLogout,
|
||||
label: AppStrings.ticketAutoLogout,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildRunButton(theme),
|
||||
if (riskStep != RiskStep.idle) ...[
|
||||
const SizedBox(height: 16),
|
||||
StepProgressCard(
|
||||
step: riskStep,
|
||||
message: riskStepMessage,
|
||||
error: riskError,
|
||||
steps: [
|
||||
(RiskStep.upload, AppStrings.stepChargeTicket),
|
||||
if (riskAutoLogout) (RiskStep.logout, AppStrings.stepLogout),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -327,7 +294,7 @@ class _TicketPageState extends State<TicketPage>
|
||||
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
onTap: _running
|
||||
onTap: riskRunning
|
||||
? null
|
||||
: () {
|
||||
setState(() {
|
||||
@@ -415,7 +382,7 @@ class _TicketPageState extends State<TicketPage>
|
||||
? _ticketName(_selectedTicketId!)
|
||||
: null;
|
||||
final onCooldown = cooldownRemaining > 0;
|
||||
final canRun = widget.loginDateTime != null && !_running && !onCooldown;
|
||||
final canRun = widget.loginDateTime != null && !onCooldown;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
@@ -437,34 +404,18 @@ class _TicketPageState extends State<TicketPage>
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
onPressed: canRun ? _runTicket : null,
|
||||
icon: _running
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
|
||||
)
|
||||
: Icon(onCooldown ? Icons.timer_outlined : Icons.play_arrow, size: 20),
|
||||
label: Text(
|
||||
_running
|
||||
? AppStrings.runningTicket
|
||||
: onCooldown
|
||||
? AppStrings.ticketCooldownCountdown(cooldownRemaining)
|
||||
: AppStrings.runTicket,
|
||||
),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
RiskRunButton(
|
||||
running: riskRunning,
|
||||
enabled: canRun,
|
||||
icon: onCooldown ? Icons.timer_outlined : Icons.play_arrow,
|
||||
label: AppStrings.runTicket,
|
||||
runningLabel: AppStrings.runningTicket,
|
||||
disabledLabel: onCooldown
|
||||
? AppStrings.ticketCooldownCountdown(cooldownRemaining)
|
||||
: null,
|
||||
onPressed: _runTicket,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
+261
-365
@@ -1,15 +1,16 @@
|
||||
|
||||
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 '../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/risk/risk_run_mixin.dart';
|
||||
import '../widgets/risk/risk_widgets.dart';
|
||||
import '../widgets/step_progress.dart';
|
||||
|
||||
/// 旅行伙伴页:发放角色 + 编组出战槽位。
|
||||
@@ -44,7 +45,7 @@ class TravelPartnerPage extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _TravelPartnerPageState extends State<TravelPartnerPage>
|
||||
with CooldownMixin<TravelPartnerPage> {
|
||||
with CooldownMixin<TravelPartnerPage>, RiskRunMixin<TravelPartnerPage> {
|
||||
final _grantIdController = TextEditingController();
|
||||
|
||||
/// 每个出战槽位一个等级输入框,留空表示不改该角色的等级。
|
||||
@@ -58,12 +59,6 @@ class _TravelPartnerPageState extends State<TravelPartnerPage>
|
||||
final List<int> _pendingGrant = [];
|
||||
List<int> _slots = normalizeCharaSlot(const []);
|
||||
|
||||
bool _loading = false;
|
||||
bool _running = false;
|
||||
bool _autoLogout = true;
|
||||
RiskStep _step = RiskStep.idle;
|
||||
String _stepMessage = '';
|
||||
|
||||
@override
|
||||
int? get cooldownLoginDateTime => widget.loginDateTime;
|
||||
|
||||
@@ -84,11 +79,13 @@ class _TravelPartnerPageState extends State<TravelPartnerPage>
|
||||
_pendingGrant.contains(characterId));
|
||||
|
||||
Future<void> _fetchData() async {
|
||||
if (!TitleServerConfigHolder().isConfigured) return;
|
||||
setState(() => _loading = true);
|
||||
final service = SessionModel.instance.createTitleService(
|
||||
cookies: widget.cookies,
|
||||
);
|
||||
if (service == null) return;
|
||||
setRiskFetching(true);
|
||||
|
||||
try {
|
||||
final service = TitleApiService.fromHolder(cookies: widget.cookies)!;
|
||||
final results = await Future.wait([
|
||||
service.getUserCharacters(widget.userId),
|
||||
service.fetchUserAllData(widget.userId),
|
||||
@@ -109,12 +106,12 @@ class _TravelPartnerPageState extends State<TravelPartnerPage>
|
||||
_userAllData = all;
|
||||
_slots = normalizeCharaSlot(slots);
|
||||
// 槽位里引用了但列表没有的角色,保留为可选,避免下拉框丢项。
|
||||
_loading = false;
|
||||
riskFetching = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _loading = false);
|
||||
_snack('${AppStrings.loadFailed}: $e');
|
||||
setState(() => riskFetching = false);
|
||||
_snack('${AppStrings.loadFailed}: ${describeApiError(e)}');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,18 +189,15 @@ class _TravelPartnerPageState extends State<TravelPartnerPage>
|
||||
}
|
||||
|
||||
Future<void> _run() async {
|
||||
if (!TitleServerConfigHolder().isConfigured) {
|
||||
_snack(AppStrings.ticketNotConfigured);
|
||||
return;
|
||||
}
|
||||
final loginDateTime = widget.loginDateTime;
|
||||
final loginId = widget.loginId;
|
||||
if (loginDateTime == null || loginId == null) {
|
||||
_snack(AppStrings.travelPartnerNotLoggedIn);
|
||||
return;
|
||||
}
|
||||
if (cooldownRemaining > 0) {
|
||||
_snack(AppStrings.travelPartnerCooldownNotice(cooldownRemaining));
|
||||
final guard = riskPreconditionError(
|
||||
loginDateTime: widget.loginDateTime,
|
||||
loginId: widget.loginId,
|
||||
cooldownRemaining: cooldownRemaining,
|
||||
notLoggedInMessage: AppStrings.travelPartnerNotLoggedIn,
|
||||
cooldownNotice: AppStrings.travelPartnerCooldownNotice,
|
||||
);
|
||||
if (guard != null) {
|
||||
_snack(guard);
|
||||
return;
|
||||
}
|
||||
final validationError = _validate();
|
||||
@@ -212,20 +206,25 @@ class _TravelPartnerPageState extends State<TravelPartnerPage>
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _running = true);
|
||||
final loginDateTime = widget.loginDateTime!;
|
||||
final loginId = widget.loginId!;
|
||||
setRiskRunning(true);
|
||||
clearRiskError();
|
||||
|
||||
try {
|
||||
final service = TitleApiService.fromHolder(cookies: widget.cookies)!;
|
||||
final service = SessionModel.instance.createTitleService(
|
||||
cookies: widget.cookies,
|
||||
)!;
|
||||
final builder = UserAllPayloadBuilder(service.config);
|
||||
|
||||
var data = _userAllData;
|
||||
if (data.isEmpty) {
|
||||
_updateStep(RiskStep.fetchData);
|
||||
updateRiskStep(RiskStep.fetchData);
|
||||
data = await service.fetchUserAllData(widget.userId);
|
||||
if (mounted) setState(() => _userAllData = data);
|
||||
}
|
||||
|
||||
_updateStep(RiskStep.upload);
|
||||
updateRiskStep(RiskStep.upload);
|
||||
final packet = builder.build(
|
||||
userId: widget.userId,
|
||||
loginId: loginId,
|
||||
@@ -273,25 +272,17 @@ class _TravelPartnerPageState extends State<TravelPartnerPage>
|
||||
// 两种失败在返回值上完全一样(returnCode 都是 1),只能靠回读区分:
|
||||
// 服务器没写入 userCharacterList,还是写入了但 ID 不在机台 Chara 表里
|
||||
// 被客户端 CharacterSelectProces 静默跳过。
|
||||
_updateStep(
|
||||
updateRiskStep(
|
||||
RiskStep.complete,
|
||||
_pendingGrant.isEmpty
|
||||
? AppStrings.travelPartnerSuccess
|
||||
: await _verifyGranted(service),
|
||||
);
|
||||
|
||||
if (_autoLogout && widget.onExitToTitle != null) {
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
if (!mounted) return;
|
||||
_updateStep(RiskStep.logout, AppStrings.exitingToTitle);
|
||||
await widget.onExitToTitle!();
|
||||
}
|
||||
} on TitleApiException catch (e) {
|
||||
_updateStep(RiskStep.failed, e.message);
|
||||
await autoLogoutAndExit(widget.onExitToTitle);
|
||||
} catch (e) {
|
||||
_updateStep(RiskStep.failed, '$e');
|
||||
reportRiskError(e);
|
||||
} finally {
|
||||
if (mounted) setState(() => _running = false);
|
||||
setRiskRunning(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -301,7 +292,7 @@ class _TravelPartnerPageState extends State<TravelPartnerPage>
|
||||
try {
|
||||
owned = await service.getUserCharacters(widget.userId);
|
||||
} catch (e) {
|
||||
return AppStrings.travelPartnerVerifyFailed('$e');
|
||||
return AppStrings.travelPartnerVerifyFailed(describeApiError(e));
|
||||
}
|
||||
if (mounted) setState(() => _owned = owned);
|
||||
|
||||
@@ -311,14 +302,6 @@ class _TravelPartnerPageState extends State<TravelPartnerPage>
|
||||
return AppStrings.travelPartnerNotSaved(missing.join(' / '));
|
||||
}
|
||||
|
||||
void _updateStep(RiskStep step, [String? message]) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_step = step;
|
||||
_stepMessage = message ?? '';
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
@@ -327,211 +310,146 @@ class _TravelPartnerPageState extends State<TravelPartnerPage>
|
||||
appBar: AppBar(
|
||||
title: const Text(AppStrings.travelPartnerFeatureTitle),
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: responsiveBody(
|
||||
context,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (!isLoggedIn)
|
||||
const AppNotice(
|
||||
AppStrings.travelPartnerNotLoggedIn,
|
||||
error: true,
|
||||
),
|
||||
if (isLoggedIn && cooldownRemaining > 0)
|
||||
AppNotice(
|
||||
AppStrings.travelPartnerCooldownNotice(cooldownRemaining),
|
||||
),
|
||||
_sectionCard(
|
||||
theme,
|
||||
icon: Icons.card_giftcard,
|
||||
title: AppStrings.travelPartnerFeatureDesc,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_fetchCard(theme),
|
||||
const SizedBox(height: 12),
|
||||
_grantCard(theme),
|
||||
const SizedBox(height: 12),
|
||||
_slotCard(theme),
|
||||
const SizedBox(height: 12),
|
||||
_ownedCard(theme),
|
||||
const SizedBox(height: 12),
|
||||
AutoLogoutToggle(
|
||||
value: _autoLogout,
|
||||
enabled: widget.onExitToTitle != null && !_running,
|
||||
onChanged: (v) => setState(() => _autoLogout = v),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_runButton(theme),
|
||||
if (_step != RiskStep.idle) ...[
|
||||
const SizedBox(height: 16),
|
||||
StepProgressCard(
|
||||
step: _step,
|
||||
message: _stepMessage,
|
||||
steps: [
|
||||
(
|
||||
RiskStep.fetchData,
|
||||
AppStrings.travelPartnerStepFetch,
|
||||
),
|
||||
(
|
||||
RiskStep.upload,
|
||||
AppStrings.travelPartnerStepUpload,
|
||||
),
|
||||
if (_autoLogout) (RiskStep.logout, AppStrings.stepLogout),
|
||||
],
|
||||
),
|
||||
body: RiskPageBody(
|
||||
children: [
|
||||
RiskStatusNotices(
|
||||
isLoggedIn: isLoggedIn,
|
||||
cooldownRemaining: cooldownRemaining,
|
||||
notLoggedInText: AppStrings.travelPartnerNotLoggedIn,
|
||||
cooldownText: AppStrings.travelPartnerCooldownNotice,
|
||||
),
|
||||
RiskFeatureCard(
|
||||
icon: Icons.card_giftcard,
|
||||
title: AppStrings.travelPartnerFeatureDesc,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
RiskFetchCard(
|
||||
hasData: _userAllData.isNotEmpty && _owned.isNotEmpty,
|
||||
statusText: _userAllData.isNotEmpty && _owned.isNotEmpty
|
||||
? AppStrings.travelPartnerFetched
|
||||
: AppStrings.travelPartnerNoData,
|
||||
loadingLabel: AppStrings.travelPartnerFetching,
|
||||
actionLabel: _userAllData.isNotEmpty
|
||||
? AppStrings.travelPartnerRefetch
|
||||
: AppStrings.travelPartnerFetchData,
|
||||
loading: riskFetching,
|
||||
enabled: !riskRunning,
|
||||
onFetch: _fetchData,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_grantCard(theme),
|
||||
const SizedBox(height: 12),
|
||||
_slotCard(theme),
|
||||
const SizedBox(height: 12),
|
||||
_ownedCard(theme),
|
||||
const SizedBox(height: 12),
|
||||
AutoLogoutToggle(
|
||||
value: riskAutoLogout,
|
||||
enabled: widget.onExitToTitle != null && !riskRunning,
|
||||
onChanged: setRiskAutoLogout,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
RiskRunButton(
|
||||
running: riskRunning,
|
||||
enabled: isLoggedIn,
|
||||
icon: Icons.send,
|
||||
label: AppStrings.travelPartnerRun,
|
||||
runningLabel: AppStrings.travelPartnerRunning,
|
||||
onPressed: _run,
|
||||
),
|
||||
if (riskStep != RiskStep.idle) ...[
|
||||
const SizedBox(height: 16),
|
||||
StepProgressCard(
|
||||
step: riskStep,
|
||||
message: riskStepMessage,
|
||||
error: riskError,
|
||||
steps: [
|
||||
(RiskStep.fetchData, AppStrings.travelPartnerStepFetch),
|
||||
(RiskStep.upload, AppStrings.travelPartnerStepUpload),
|
||||
if (riskAutoLogout) (RiskStep.logout, AppStrings.stepLogout),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _sectionCard(
|
||||
ThemeData theme, {
|
||||
required IconData icon,
|
||||
required String title,
|
||||
List<Widget> children = const [],
|
||||
}) {
|
||||
return AppCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SectionTitle(icon: icon, title: title),
|
||||
...children,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _fetchCard(ThemeData theme) {
|
||||
final hasData = _userAllData.isNotEmpty && _owned.isNotEmpty;
|
||||
return AppCard(
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
hasData ? Icons.check_circle : Icons.download,
|
||||
size: 18,
|
||||
color: hasData ? Colors.green : theme.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
hasData
|
||||
? AppStrings.travelPartnerFetched
|
||||
: AppStrings.travelPartnerNoData,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: hasData ? Colors.green : theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: 32,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _loading || _running ? null : _fetchData,
|
||||
icon: _loading
|
||||
? const SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.refresh, size: 16),
|
||||
label: Text(
|
||||
_loading
|
||||
? AppStrings.travelPartnerFetching
|
||||
: _userAllData.isNotEmpty
|
||||
? AppStrings.travelPartnerRefetch
|
||||
: AppStrings.travelPartnerFetchData,
|
||||
style: theme.textTheme.labelSmall,
|
||||
),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _grantCard(ThemeData theme) {
|
||||
final enabled = !_running;
|
||||
return _sectionCard(
|
||||
theme,
|
||||
final enabled = !riskRunning;
|
||||
return RiskFeatureCard(
|
||||
icon: Icons.add_circle_outline,
|
||||
title: AppStrings.travelPartnerGrantTitle,
|
||||
children: [
|
||||
const SizedBox(height: 14),
|
||||
TextField(
|
||||
controller: _grantIdController,
|
||||
enabled: enabled,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: InputDecoration(
|
||||
labelText: AppStrings.travelPartnerCharacterIdLabel,
|
||||
hintText: AppStrings.travelPartnerCharacterIdHint,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
contentPadding: const EdgeInsets.all(14),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: enabled ? _addGrant : null,
|
||||
icon: const Icon(Icons.add, size: 18),
|
||||
label: const Text(AppStrings.listAddButton),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 14),
|
||||
TextField(
|
||||
controller: _grantIdController,
|
||||
enabled: enabled,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: InputDecoration(
|
||||
labelText: AppStrings.travelPartnerCharacterIdLabel,
|
||||
hintText: AppStrings.travelPartnerCharacterIdHint,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
contentPadding: const EdgeInsets.all(14),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: enabled ? _addGrant : null,
|
||||
icon: const Icon(Icons.add, size: 18),
|
||||
label: const Text(AppStrings.listAddButton),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
_pendingGrant.isEmpty
|
||||
? AppStrings.travelPartnerGrantEmpty
|
||||
: _pendingGrant.map((e) => '#$e').join(' '),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
color: _pendingGrant.isEmpty
|
||||
? theme.colorScheme.onSurfaceVariant
|
||||
: theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
AppStrings.travelPartnerIdRangeTitle,
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
AppStrings.travelPartnerIdRangeBody,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
if (_pendingGrant.any(_isSuspectId)) ...[
|
||||
const SizedBox(height: 8),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
AppStrings.travelPartnerIdUnknown,
|
||||
_pendingGrant.isEmpty
|
||||
? AppStrings.travelPartnerGrantEmpty
|
||||
: _pendingGrant.map((e) => '#$e').join(' '),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.error,
|
||||
fontFamily: 'monospace',
|
||||
color: _pendingGrant.isEmpty
|
||||
? theme.colorScheme.onSurfaceVariant
|
||||
: theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
AppStrings.travelPartnerIdRangeTitle,
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
AppStrings.travelPartnerIdRangeBody,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
if (_pendingGrant.any(_isSuspectId)) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
AppStrings.travelPartnerIdUnknown,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -558,60 +476,23 @@ class _TravelPartnerPageState extends State<TravelPartnerPage>
|
||||
}.toList()
|
||||
..sort();
|
||||
|
||||
return _sectionCard(
|
||||
theme,
|
||||
return RiskFeatureCard(
|
||||
icon: Icons.groups_outlined,
|
||||
title: AppStrings.travelPartnerSlotTitle,
|
||||
children: [
|
||||
const SizedBox(height: 14),
|
||||
for (var i = 0; i < 5; i++)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
DropdownButtonFormField<int>(
|
||||
initialValue: _slots[i],
|
||||
decoration: InputDecoration(
|
||||
labelText: AppStrings.travelPartnerSlotLabel(i),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 14,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
items: [
|
||||
DropdownMenuItem(
|
||||
value: 0,
|
||||
child: Text(
|
||||
AppStrings.travelPartnerSlotNone,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
for (final id in optionIds)
|
||||
DropdownMenuItem(value: id, child: Text('#$id')),
|
||||
],
|
||||
onChanged: _running
|
||||
? null
|
||||
: (v) => setState(() {
|
||||
final next = [..._slots];
|
||||
next[i] = v ?? 0;
|
||||
_slots = next;
|
||||
}),
|
||||
),
|
||||
if (_slots[i] != 0) ...[
|
||||
const SizedBox(height: 6),
|
||||
TextField(
|
||||
controller: _slotLevelControllers[i],
|
||||
enabled: !_running,
|
||||
keyboardType: TextInputType.number,
|
||||
onChanged: (_) => setState(() {}),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const SizedBox(height: 14),
|
||||
for (var i = 0; i < 5; i++)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
DropdownButtonFormField<int>(
|
||||
initialValue: _slots[i],
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
labelText: AppStrings.travelPartnerLevelLabel,
|
||||
hintText: AppStrings.travelPartnerLevelHint,
|
||||
labelText: AppStrings.travelPartnerSlotLabel(i),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
@@ -620,27 +501,66 @@ class _TravelPartnerPageState extends State<TravelPartnerPage>
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
items: [
|
||||
DropdownMenuItem(
|
||||
value: 0,
|
||||
child: Text(
|
||||
AppStrings.travelPartnerSlotNone,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
for (final id in optionIds)
|
||||
DropdownMenuItem(value: id, child: Text('#$id')),
|
||||
],
|
||||
onChanged: riskRunning
|
||||
? null
|
||||
: (v) => setState(() {
|
||||
final next = [..._slots];
|
||||
next[i] = v ?? 0;
|
||||
_slots = next;
|
||||
}),
|
||||
),
|
||||
..._levelHintLines(theme, _slotLevelControllers[i].text),
|
||||
if (_slots[i] != 0) ...[
|
||||
const SizedBox(height: 6),
|
||||
TextField(
|
||||
controller: _slotLevelControllers[i],
|
||||
enabled: !riskRunning,
|
||||
keyboardType: TextInputType.number,
|
||||
onChanged: (_) => setState(() {}),
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
labelText: AppStrings.travelPartnerLevelLabel,
|
||||
hintText: AppStrings.travelPartnerLevelHint,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 14,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
..._levelHintLines(theme, _slotLevelControllers[i].text),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _running ? null : _copySlot0ToOthers,
|
||||
icon: const Icon(Icons.copy_all_outlined, size: 18),
|
||||
label: const Text(AppStrings.travelPartnerCopySlot0),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: riskRunning ? null : _copySlot0ToOthers,
|
||||
icon: const Icon(Icons.copy_all_outlined, size: 18),
|
||||
label: const Text(AppStrings.travelPartnerCopySlot0),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -664,70 +584,46 @@ class _TravelPartnerPageState extends State<TravelPartnerPage>
|
||||
}
|
||||
|
||||
Widget _ownedCard(ThemeData theme) {
|
||||
return _sectionCard(
|
||||
theme,
|
||||
return RiskFeatureCard(
|
||||
icon: Icons.workspace_premium_outlined,
|
||||
title:
|
||||
'${AppStrings.travelPartnerOwnedTitle} (${_owned.length})',
|
||||
children: [
|
||||
const SizedBox(height: 14),
|
||||
if (_owned.isEmpty)
|
||||
Text(
|
||||
AppStrings.travelPartnerOwnedEmpty,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
)
|
||||
else
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 6,
|
||||
children: [
|
||||
for (final c in _owned)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest
|
||||
.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
'#${c.characterId} Lv${c.level}',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
title: '${AppStrings.travelPartnerOwnedTitle} (${_owned.length})',
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 14),
|
||||
if (_owned.isEmpty)
|
||||
Text(
|
||||
AppStrings.travelPartnerOwnedEmpty,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
)
|
||||
else
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 6,
|
||||
children: [
|
||||
for (final c in _owned)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest
|
||||
.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
'#${c.characterId} Lv${c.level}',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _runButton(ThemeData theme) {
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
onPressed: _running || !isLoggedIn ? null : _run,
|
||||
icon: _running
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.send, size: 20),
|
||||
label: Text(
|
||||
_running ? AppStrings.travelPartnerRunning : AppStrings.travelPartnerRun,
|
||||
),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user