refactor: 拆分主页与公共组件,扫码登录改走 WinHTTP

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

No files matched your search

+6 -5
View File
@@ -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));
}
}
+98 -184
View File
@@ -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,
+23
View File
@@ -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);
}
+306
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+89 -210
View File
@@ -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
View File
@@ -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
View File
@@ -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),
),
),
),
);
}
}
+158 -662
View File
@@ -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,
);
}
}
+91 -199
View File
@@ -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,
);
}
}
+19 -14
View File
@@ -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
View File
@@ -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
View File
@@ -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),
),
),
],
),
],
),
);
}