refactor: 拆分主页与公共组件,扫码登录改走 WinHTTP
- 抽出 lib/core 的日期/JSON 工具,收敛各页重复解析 - 主页与音乐风险页拆成组件,风险功能共用一套运行框架 - 修复设置页作为主页页签保存时弹掉根路由导致的黑屏 - 登录对齐 Astral aimedb 协议:84 位二维码校验、签名与请求头修正, 补 GlobalSign 中间证书;Windows 改用 WinHTTP 绕过服务端对 BoringSSL 的 TLS 指纹拦截(证书校验保持开启) - 补开发预览数据与测试
This commit is contained in:
1 parent
0cb5320290
commit
e9357f5969
55 files changed
+4387
-3392
No files matched your search
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart' show Clipboard, ClipboardData;
|
||||
|
||||
import '../config/strings.dart';
|
||||
import '../models/session_model.dart';
|
||||
import '../services/account_backup_service.dart';
|
||||
import '../services/title_api_service.dart';
|
||||
import 'app_card.dart';
|
||||
@@ -28,7 +29,9 @@ class _AccountBackupCardState extends State<AccountBackupCard> {
|
||||
String? _error;
|
||||
|
||||
Future<void> _run() async {
|
||||
final service = TitleApiService.fromHolder(cookies: widget.cookies);
|
||||
final service = SessionModel.instance.createTitleService(
|
||||
cookies: widget.cookies,
|
||||
);
|
||||
if (service == null) {
|
||||
context.showSnack(AppStrings.ticketNotConfigured);
|
||||
return;
|
||||
@@ -56,7 +59,7 @@ class _AccountBackupCardState extends State<AccountBackupCard> {
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_error = '$e';
|
||||
_error = describeApiError(e);
|
||||
_running = false;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ class AppCard extends StatelessWidget {
|
||||
final EdgeInsetsGeometry padding;
|
||||
final Color? borderColor;
|
||||
final EdgeInsetsGeometry? margin;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const AppCard({
|
||||
super.key,
|
||||
@@ -18,11 +19,14 @@ class AppCard extends StatelessWidget {
|
||||
this.padding = const EdgeInsets.all(20),
|
||||
this.borderColor,
|
||||
this.margin,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final content = Padding(padding: padding, child: child);
|
||||
|
||||
return Card(
|
||||
elevation: 0,
|
||||
margin: margin,
|
||||
@@ -33,7 +37,13 @@ class AppCard extends StatelessWidget {
|
||||
theme.colorScheme.outline.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
child: Padding(padding: padding, child: child),
|
||||
child: onTap == null
|
||||
? content
|
||||
: InkWell(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
onTap: onTap,
|
||||
child: content,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../config/strings.dart';
|
||||
import '../app_card.dart';
|
||||
|
||||
/// 主页头像:iconId 为 0 时显示占位人形。
|
||||
class HomeAvatar extends StatelessWidget {
|
||||
final int iconId;
|
||||
|
||||
const HomeAvatar({super.key, required this.iconId});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return CircleAvatar(
|
||||
radius: 30,
|
||||
backgroundColor: theme.colorScheme.primaryContainer,
|
||||
backgroundImage: iconId > 0
|
||||
? NetworkImage('https://assets2.lxns.net/maimai/icon/$iconId.png')
|
||||
: null,
|
||||
child: iconId == 0
|
||||
? Icon(Icons.person, color: theme.colorScheme.onPrimaryContainer)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 主页顶部的账号概要卡,Preview 与完整数据共用。
|
||||
///
|
||||
/// [highestRating] 为 null 时不显示「历史最高」一行(Preview 数据里没有)。
|
||||
class HomeProfileCard extends StatelessWidget {
|
||||
final String userName;
|
||||
final int iconId;
|
||||
final int playerRating;
|
||||
final String? highestRating;
|
||||
|
||||
const HomeProfileCard({
|
||||
super.key,
|
||||
required this.userName,
|
||||
required this.iconId,
|
||||
required this.playerRating,
|
||||
this.highestRating,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return AppCard(
|
||||
child: Row(
|
||||
children: [
|
||||
HomeAvatar(iconId: iconId),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
userName.isEmpty ? AppStrings.unknownUser : userName,
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
AppStrings.rating(playerRating),
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
if (highestRating != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'${AppStrings.highestRating}: $highestRating',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 主页的「标题 + 字段行」信息卡。
|
||||
class HomeSectionCard extends StatelessWidget {
|
||||
final String title;
|
||||
final List<(String, String)> rows;
|
||||
|
||||
const HomeSectionCard({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.rows,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return AppCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
for (final (label, value) in rows)
|
||||
_InfoRow(label: label, value: value),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoRow extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
const _InfoRow({required this.label, required this.value});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 110,
|
||||
child: Text(
|
||||
label,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value.isEmpty ? '-' : value,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 主页状态卡:标题 + 若干 [HomeStatusChip]。
|
||||
class HomeStatusCard extends StatelessWidget {
|
||||
final List<Widget> chips;
|
||||
|
||||
const HomeStatusCard({super.key, required this.chips});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return AppCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
AppStrings.status,
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
...chips,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 状态行:bool 按成功/失败色渲染成胶囊,其他值原样显示。
|
||||
class HomeStatusChip extends StatelessWidget {
|
||||
final String label;
|
||||
final Object? value;
|
||||
final String trueText;
|
||||
final String falseText;
|
||||
|
||||
const HomeStatusChip(
|
||||
this.label,
|
||||
this.value, {
|
||||
super.key,
|
||||
this.trueText = 'Yes',
|
||||
this.falseText = 'No',
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final v = value;
|
||||
final String text;
|
||||
final Color color;
|
||||
if (v is bool) {
|
||||
text = v ? trueText : falseText;
|
||||
color = v ? Colors.green : Colors.grey;
|
||||
} else {
|
||||
text = '$v';
|
||||
color = theme.colorScheme.onSurface;
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 110,
|
||||
child: Text(
|
||||
label,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
text,
|
||||
style: theme.textTheme.labelSmall?.copyWith(color: color),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../config/strings.dart';
|
||||
import '../app_card.dart';
|
||||
|
||||
/// 调试用的原始 API 响应卡,默认折叠。
|
||||
class DebugRawJsonCard extends StatefulWidget {
|
||||
final String rawJson;
|
||||
|
||||
const DebugRawJsonCard({super.key, required this.rawJson});
|
||||
|
||||
@override
|
||||
State<DebugRawJsonCard> createState() => _DebugRawJsonCardState();
|
||||
}
|
||||
|
||||
class _DebugRawJsonCardState extends State<DebugRawJsonCard> {
|
||||
bool _expanded = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return AppCard(
|
||||
borderColor: Colors.orange.withValues(alpha: 0.5),
|
||||
onTap: () => setState(() => _expanded = !_expanded),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.bug_report, size: 16, color: Colors.orange),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
AppStrings.rawApiResponse,
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: Colors.orange,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
_expanded ? Icons.expand_less : Icons.expand_more,
|
||||
color: Colors.orange,
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_expanded) ...[
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest.withValues(
|
||||
alpha: 0.5,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: SelectableText(
|
||||
widget.rawJson,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ErrorIdBanner extends StatelessWidget {
|
||||
final int errorId;
|
||||
|
||||
const ErrorIdBanner({super.key, required this.errorId});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.warning_amber_rounded,
|
||||
size: 20,
|
||||
color: theme.colorScheme.onErrorContainer,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'API returned errorId: $errorId',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onErrorContainer,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../config/strings.dart';
|
||||
import '../../models/user_preview.dart';
|
||||
import 'home_common.dart';
|
||||
|
||||
class PreviewGameInfoCard extends StatelessWidget {
|
||||
final UserPreviewDataBean preview;
|
||||
|
||||
const PreviewGameInfoCard({super.key, required this.preview});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return HomeSectionCard(
|
||||
title: AppStrings.gameInfo,
|
||||
rows: [
|
||||
(AppStrings.lastGame, preview.lastGameId),
|
||||
(AppStrings.lastPlay, preview.lastPlayDate),
|
||||
(AppStrings.lastLogin, preview.lastLoginDate),
|
||||
(AppStrings.romVersion, preview.lastRomVersion),
|
||||
(AppStrings.dataVersion, preview.lastDataVersion),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PreviewStatusCard extends StatelessWidget {
|
||||
final UserPreviewDataBean preview;
|
||||
|
||||
const PreviewStatusCard({super.key, required this.preview});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return HomeStatusCard(
|
||||
chips: [
|
||||
HomeStatusChip(AppStrings.netMember, preview.isNetMember),
|
||||
HomeStatusChip(AppStrings.inherit, preview.isInherit),
|
||||
HomeStatusChip(
|
||||
AppStrings.banState,
|
||||
preview.banState == 0,
|
||||
trueText: AppStrings.clean,
|
||||
falseText: '已封禁 (${preview.banState})',
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../config/strings.dart';
|
||||
import '../../models/user_data.dart';
|
||||
import '../../models/user_preview.dart';
|
||||
import '../app_card.dart';
|
||||
import 'home_common.dart';
|
||||
|
||||
class RatingBreakdownCard extends StatelessWidget {
|
||||
final UserDataBean data;
|
||||
|
||||
const RatingBreakdownCard({super.key, required this.data});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return HomeSectionCard(
|
||||
title: AppStrings.ratingBreakdown,
|
||||
rows: [
|
||||
(AppStrings.musicRating, '${data.musicRating}'),
|
||||
(AppStrings.newChartRating, '${data.playerNewRating}'),
|
||||
(AppStrings.oldChartRating, '${data.playerOldRating}'),
|
||||
(AppStrings.highestRating, '${data.highestRating}'),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class FirstPlayCard extends StatelessWidget {
|
||||
final UserDataBean data;
|
||||
|
||||
const FirstPlayCard({super.key, required this.data});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return HomeSectionCard(
|
||||
title: AppStrings.firstPlay,
|
||||
rows: [
|
||||
(AppStrings.joinDate, data.firstPlayDate),
|
||||
(AppStrings.firstGame, data.firstGameId),
|
||||
(AppStrings.firstRomVersion, data.firstRomVersion),
|
||||
(AppStrings.firstDataVersion, data.firstDataVersion),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class GameInfoFromUserDataCard extends StatelessWidget {
|
||||
final UserDataBean data;
|
||||
|
||||
const GameInfoFromUserDataCard({super.key, required this.data});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return HomeSectionCard(
|
||||
title: AppStrings.gameInfo,
|
||||
rows: [
|
||||
(AppStrings.lastGame, data.lastGameId),
|
||||
(AppStrings.lastPlay, data.lastPlayDate),
|
||||
(AppStrings.lastLogin, data.lastLoginDate),
|
||||
(AppStrings.romVersion, data.lastRomVersion),
|
||||
(AppStrings.dataVersion, data.lastDataVersion),
|
||||
(
|
||||
AppStrings.lastRegion,
|
||||
'${data.lastRegionName}${data.lastRegionId > 0 ? ' (${data.lastRegionId})' : ''}',
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PlayStatsCard extends StatelessWidget {
|
||||
final UserDataBean data;
|
||||
|
||||
const PlayStatsCard({super.key, required this.data});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return HomeSectionCard(
|
||||
title: AppStrings.playStats,
|
||||
rows: [
|
||||
(AppStrings.playCount, '${data.playCount}'),
|
||||
(AppStrings.currentPlayCount, '${data.currentPlayCount}'),
|
||||
(AppStrings.totalAchievement, '${data.totalAchievement}'),
|
||||
(AppStrings.totalDxScore, '${data.totalDeluxscore}'),
|
||||
(AppStrings.totalSync, '${data.totalSync}'),
|
||||
(AppStrings.totalAwake, '${data.totalAwake}'),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class StatusFromUserDataCard extends StatelessWidget {
|
||||
/// 用连接信息(仅 JSESSIONID)恢复的会话没有走 GetUserPreviewApi,可为空。
|
||||
final UserPreviewDataBean? preview;
|
||||
final UserDataBean data;
|
||||
|
||||
const StatusFromUserDataCard({
|
||||
super.key,
|
||||
required this.preview,
|
||||
required this.data,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final p = preview;
|
||||
|
||||
return HomeStatusCard(
|
||||
chips: [
|
||||
HomeStatusChip(AppStrings.netMember, data.isNetMember),
|
||||
if (p != null) HomeStatusChip(AppStrings.inherit, p.isInherit),
|
||||
HomeStatusChip(
|
||||
AppStrings.banState,
|
||||
data.banState == 0,
|
||||
trueText: AppStrings.clean,
|
||||
falseText: '已封禁 (${data.banState})',
|
||||
),
|
||||
HomeStatusChip(
|
||||
AppStrings.dailyBonus,
|
||||
data.dailyBonusDate.isNotEmpty &&
|
||||
!data.dailyBonusDate.startsWith('1970'),
|
||||
trueText: data.dailyBonusDate,
|
||||
falseText: AppStrings.notClaimed,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 可展开的原始字段卡。
|
||||
class UserDataDetailCard extends StatefulWidget {
|
||||
final UserDataBean data;
|
||||
|
||||
const UserDataDetailCard({super.key, required this.data});
|
||||
|
||||
@override
|
||||
State<UserDataDetailCard> createState() => _UserDataDetailCardState();
|
||||
}
|
||||
|
||||
class _UserDataDetailCardState extends State<UserDataDetailCard> {
|
||||
bool _expanded = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final data = widget.data;
|
||||
|
||||
return AppCard(
|
||||
onTap: () => setState(() => _expanded = !_expanded),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
AppStrings.moreDetails,
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
_expanded ? Icons.expand_less : Icons.expand_more,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_expanded) ...[
|
||||
const SizedBox(height: 14),
|
||||
_detailRow(theme, AppStrings.userId, '${data.userId}'),
|
||||
_detailRow(theme, AppStrings.point, '${data.point}'),
|
||||
_detailRow(theme, AppStrings.totalPoint, '${data.totalPoint}'),
|
||||
_detailRow(theme, AppStrings.iconId, '${data.iconId}'),
|
||||
_detailRow(theme, AppStrings.nameplateId, '${data.nameplateId}'),
|
||||
_detailRow(theme, AppStrings.plateId, '${data.plateId}'),
|
||||
_detailRow(theme, AppStrings.frameId, '${data.frameId}'),
|
||||
_detailRow(theme, AppStrings.titleId, '${data.titleId}'),
|
||||
_detailRow(theme, AppStrings.trophyId, '${data.trophyId}'),
|
||||
_detailRow(theme, AppStrings.partnerId, '${data.partnerId}'),
|
||||
_detailRow(theme, AppStrings.gradeRank, '${data.gradeRank}'),
|
||||
_detailRow(theme, AppStrings.courseRank, '${data.courseRank}'),
|
||||
_detailRow(theme, AppStrings.classRank, '${data.classRank}'),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _detailRow(ThemeData theme, String label, String value) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 130,
|
||||
child: Text(
|
||||
label,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(child: Text(value, style: theme.textTheme.bodyMedium)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../config/strings.dart';
|
||||
import '../app_card.dart';
|
||||
import 'risk_list_widgets.dart';
|
||||
import 'risk_widgets.dart';
|
||||
|
||||
/// 一行待提交的收藏品道具(对应 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);
|
||||
|
||||
/// 难度由 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('/');
|
||||
|
||||
/// ConstParameter.DxScoreBaseID=10000 / StrongScoreBaseID=20000
|
||||
/// (`MAI2System/ConstParameter.cs:168,171`)。这段里的歌 Master 需要
|
||||
/// 显式 itemKind 6,不像 id < 10000 那样在 IsUnlockMaster 里直接放过。
|
||||
bool get isDeluxeRange => musicId >= 10000 && musicId < 20000;
|
||||
}
|
||||
|
||||
/// 歌曲解锁的输入卡。输入框与三个难度勾选由本组件持有,
|
||||
/// 校验通过后把待提交行交给父级;父级返回 false 表示重复、保留输入。
|
||||
class UnlockMusicEditorCard extends StatefulWidget {
|
||||
final bool enabled;
|
||||
final bool Function(PendingMusic music) onAdd;
|
||||
final ValueChanged<String> onError;
|
||||
final Widget pendingList;
|
||||
final bool showDxNotice;
|
||||
|
||||
const UnlockMusicEditorCard({
|
||||
super.key,
|
||||
required this.enabled,
|
||||
required this.onAdd,
|
||||
required this.onError,
|
||||
required this.pendingList,
|
||||
required this.showDxNotice,
|
||||
});
|
||||
|
||||
@override
|
||||
State<UnlockMusicEditorCard> createState() => _UnlockMusicEditorCardState();
|
||||
}
|
||||
|
||||
class _UnlockMusicEditorCardState extends State<UnlockMusicEditorCard> {
|
||||
final _controller = TextEditingController();
|
||||
bool _music = false;
|
||||
bool _master = false;
|
||||
bool _remaster = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _add() {
|
||||
final id = int.tryParse(_controller.text.trim()) ?? -1;
|
||||
if (id <= 0) {
|
||||
widget.onError(AppStrings.unlockNeedMusicId);
|
||||
return;
|
||||
}
|
||||
if (!_music && !_master && !_remaster) {
|
||||
widget.onError(AppStrings.unlockNeedOption);
|
||||
return;
|
||||
}
|
||||
|
||||
final added = widget.onAdd(PendingMusic(id, _music, _master, _remaster));
|
||||
if (!added) return;
|
||||
|
||||
setState(() {
|
||||
_controller.clear();
|
||||
_music = _master = _remaster = false;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final enabled = widget.enabled;
|
||||
|
||||
return AppCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
RiskNumberField(
|
||||
controller: _controller,
|
||||
label: AppStrings.unlockMusicIdLabel,
|
||||
hint: AppStrings.unlockMusicIdHint,
|
||||
enabled: enabled,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
RiskCheckRow(
|
||||
value: _music,
|
||||
label: AppStrings.unlockMusicOption,
|
||||
enabled: enabled,
|
||||
onChanged: (v) => setState(() => _music = v),
|
||||
),
|
||||
RiskCheckRow(
|
||||
value: _master,
|
||||
label: AppStrings.unlockMasterOption,
|
||||
enabled: enabled,
|
||||
onChanged: (v) => setState(() => _master = v),
|
||||
),
|
||||
RiskCheckRow(
|
||||
value: _remaster,
|
||||
label: AppStrings.unlockRemasterOption,
|
||||
enabled: enabled,
|
||||
onChanged: (v) => setState(() => _remaster = v),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
RiskAddButton(onPressed: enabled ? _add : null),
|
||||
widget.pendingList,
|
||||
// DX 段的 Master / Re:Master 不白送,勾选项里漏一个就开不出来,
|
||||
// 这是这段 ID 最容易踩的坑,只在列表真含 DX 曲时才说明。
|
||||
if (widget.showDxNotice) ...[
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
AppStrings.unlockDxRangeNotice,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.error,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 收藏品获取的输入卡(类型下拉 + ID)。
|
||||
class CollectiblesEditorCard extends StatefulWidget {
|
||||
final bool enabled;
|
||||
final bool Function(PendingItem item) onAdd;
|
||||
final ValueChanged<String> onError;
|
||||
final Widget pendingList;
|
||||
|
||||
const CollectiblesEditorCard({
|
||||
super.key,
|
||||
required this.enabled,
|
||||
required this.onAdd,
|
||||
required this.onError,
|
||||
required this.pendingList,
|
||||
});
|
||||
|
||||
@override
|
||||
State<CollectiblesEditorCard> createState() => _CollectiblesEditorCardState();
|
||||
}
|
||||
|
||||
class _CollectiblesEditorCardState extends State<CollectiblesEditorCard> {
|
||||
final _controller = TextEditingController();
|
||||
int _kind = 3;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _add() {
|
||||
final id = int.tryParse(_controller.text.trim()) ?? -1;
|
||||
if (id <= 0) {
|
||||
widget.onError(AppStrings.collectiblesNeedItemId);
|
||||
return;
|
||||
}
|
||||
|
||||
final added = widget.onAdd(PendingItem(_kind, id));
|
||||
if (!added) return;
|
||||
|
||||
setState(() => _controller.clear());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final enabled = widget.enabled;
|
||||
|
||||
return AppCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
RiskDropdownField<int>(
|
||||
value: _kind,
|
||||
label: AppStrings.collectiblesItemKindLabel,
|
||||
enabled: enabled,
|
||||
items: [
|
||||
for (final kind in AppStrings.collectiblesItemKinds)
|
||||
DropdownMenuItem(
|
||||
value: kind,
|
||||
child: Text(
|
||||
'${AppStrings.collectiblesItemKindName(kind)} ($kind)',
|
||||
),
|
||||
),
|
||||
],
|
||||
onChanged: (v) => setState(() => _kind = v),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
RiskNumberField(
|
||||
controller: _controller,
|
||||
label: AppStrings.collectiblesItemIdLabel,
|
||||
hint: AppStrings.collectiblesItemIdHint,
|
||||
enabled: enabled,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
RiskAddButton(onPressed: enabled ? _add : null),
|
||||
widget.pendingList,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../config/strings.dart';
|
||||
|
||||
/// 全宽的「添加到列表」按钮,样式统一。
|
||||
class RiskAddButton extends StatelessWidget {
|
||||
final VoidCallback? onPressed;
|
||||
|
||||
const RiskAddButton({super.key, this.onPressed});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 待提交列表:标题带数量,空列表时显示提示。
|
||||
class RiskPendingList extends StatelessWidget {
|
||||
final String title;
|
||||
final String emptyHint;
|
||||
final int count;
|
||||
final List<Widget> rows;
|
||||
|
||||
const RiskPendingList({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.emptyHint,
|
||||
required this.count,
|
||||
required this.rows,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 14),
|
||||
Text(
|
||||
'$title ($count)',
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
if (count == 0)
|
||||
Text(
|
||||
emptyHint,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
)
|
||||
else
|
||||
...rows,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 待提交列表的一行:等宽文本 + 删除按钮。
|
||||
class RiskPendingRow extends StatelessWidget {
|
||||
final String leading;
|
||||
final String trailing;
|
||||
final VoidCallback onRemove;
|
||||
final bool enabled;
|
||||
|
||||
const RiskPendingRow({
|
||||
super.key,
|
||||
required this.leading,
|
||||
required this.trailing,
|
||||
required this.onRemove,
|
||||
this.enabled = true,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'$leading $trailing',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, size: 18),
|
||||
tooltip: AppStrings.listRemoveTooltip,
|
||||
onPressed: enabled ? onRemove : null,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 风险页通用下拉框(圆角 10、内容内边距 14)。
|
||||
class RiskDropdownField<T> extends StatelessWidget {
|
||||
final T value;
|
||||
final String label;
|
||||
final List<DropdownMenuItem<T>> items;
|
||||
final ValueChanged<T>? onChanged;
|
||||
final bool enabled;
|
||||
|
||||
const RiskDropdownField({
|
||||
super.key,
|
||||
required this.value,
|
||||
required this.label,
|
||||
required this.items,
|
||||
required this.onChanged,
|
||||
this.enabled = true,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DropdownButtonFormField<T>(
|
||||
initialValue: value,
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
contentPadding: const EdgeInsets.all(14),
|
||||
),
|
||||
items: items,
|
||||
onChanged: !enabled || onChanged == null
|
||||
? null
|
||||
: (v) {
|
||||
if (v == null) return;
|
||||
onChanged!(v);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 与 [RiskDropdownField] 配对的普通文本输入框,保持同样的圆角与内边距。
|
||||
class RiskNumberField extends StatelessWidget {
|
||||
final TextEditingController controller;
|
||||
final String label;
|
||||
final String hint;
|
||||
final bool enabled;
|
||||
|
||||
const RiskNumberField({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.label,
|
||||
required this.hint,
|
||||
this.enabled = true,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TextField(
|
||||
controller: controller,
|
||||
enabled: enabled,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
hintText: hint,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
contentPadding: const EdgeInsets.all(14),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import '../../config/strings.dart';
|
||||
import '../../config/title_server_config.dart';
|
||||
import '../../services/title_api_service.dart';
|
||||
import '../step_progress.dart';
|
||||
|
||||
/// 高危功能共用的执行状态:发包中、自动退登、阶段与错误。
|
||||
///
|
||||
/// 六个页面原来各自维护一份完全相同的字段和 `_updateStep`,导致同样的
|
||||
/// 「失败落到哪一步、退登时序怎么走」要在每个文件里各看一遍。
|
||||
mixin RiskRunMixin<T extends StatefulWidget> on State<T> {
|
||||
bool riskFetching = false;
|
||||
bool riskRunning = false;
|
||||
bool riskAutoLogout = true;
|
||||
RiskStep riskStep = RiskStep.idle;
|
||||
String riskStepMessage = '';
|
||||
String? riskError;
|
||||
|
||||
void updateRiskStep(RiskStep step, [String? message]) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
riskStep = step;
|
||||
riskStepMessage = message ?? '';
|
||||
});
|
||||
}
|
||||
|
||||
void setRiskFetching(bool value) {
|
||||
if (!mounted) return;
|
||||
setState(() => riskFetching = value);
|
||||
}
|
||||
|
||||
void setRiskRunning(bool value) {
|
||||
if (!mounted) return;
|
||||
setState(() => riskRunning = value);
|
||||
}
|
||||
|
||||
void setRiskAutoLogout(bool value) {
|
||||
if (!mounted) return;
|
||||
setState(() => riskAutoLogout = value);
|
||||
}
|
||||
|
||||
void clearRiskError() {
|
||||
if (!mounted || riskError == null) return;
|
||||
setState(() => riskError = null);
|
||||
}
|
||||
|
||||
/// 把异常写进阶段卡。`TitleApiException` 的 message 已经是给用户看的文案,
|
||||
/// 其他异常保留 `toString()` 以便排查。
|
||||
void reportRiskError(Object error) {
|
||||
final message = describeApiError(error);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
riskError = message;
|
||||
riskStep = RiskStep.failed;
|
||||
riskStepMessage = message;
|
||||
});
|
||||
}
|
||||
|
||||
/// 成功后的自动退登:留一点时间让阶段卡显示完成态,再由 HomePage 结算并
|
||||
/// pop 回标题页。调用方负责先判断 `onExitToTitle` 是否存在。
|
||||
Future<void> autoLogoutAndExit(
|
||||
Future<void> Function()? onExitToTitle, {
|
||||
Duration delay = const Duration(seconds: 2),
|
||||
}) async {
|
||||
if (!riskAutoLogout || onExitToTitle == null) return;
|
||||
await Future<void>.delayed(delay);
|
||||
if (!mounted) return;
|
||||
updateRiskStep(RiskStep.logout, AppStrings.exitingToTitle);
|
||||
await onExitToTitle();
|
||||
}
|
||||
}
|
||||
|
||||
/// 执行前的统一门禁,顺序固定为「配置 → 登录态 → 冷却」。
|
||||
///
|
||||
/// 返回要提示给用户的文案,`null` 表示可以继续。功能票只依赖
|
||||
/// `loginDateTime`,其余 `UpsertUserAll` 页面还需要 `loginId`。
|
||||
String? riskPreconditionError({
|
||||
required int? loginDateTime,
|
||||
required int? loginId,
|
||||
required int cooldownRemaining,
|
||||
required String notLoggedInMessage,
|
||||
required String Function(int remaining) cooldownNotice,
|
||||
bool requireLoginId = true,
|
||||
}) {
|
||||
if (!TitleServerConfigHolder().isConfigured) {
|
||||
return AppStrings.ticketNotConfigured;
|
||||
}
|
||||
if (loginDateTime == null || (requireLoginId && loginId == null)) {
|
||||
return notLoggedInMessage;
|
||||
}
|
||||
if (cooldownRemaining > 0) {
|
||||
return cooldownNotice(cooldownRemaining);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../config/responsive.dart';
|
||||
import '../app_card.dart';
|
||||
import '../app_notice.dart';
|
||||
|
||||
/// 尚未配置 Title Server 时的占位视图。
|
||||
class RiskConfigMissingView extends StatelessWidget {
|
||||
final String message;
|
||||
|
||||
const RiskConfigMissingView({
|
||||
super.key,
|
||||
required this.message,
|
||||
});
|
||||
|
||||
@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: 48,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(message, style: theme.textTheme.titleMedium),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 高危页统一的滚动容器与内边距。
|
||||
class RiskPageBody extends StatelessWidget {
|
||||
final List<Widget> children;
|
||||
|
||||
const RiskPageBody({super.key, required this.children});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: responsiveBody(
|
||||
context,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: children,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 未登录 / 冷却横幅。
|
||||
class RiskStatusNotices extends StatelessWidget {
|
||||
final bool isLoggedIn;
|
||||
final int cooldownRemaining;
|
||||
final String notLoggedInText;
|
||||
final String Function(int remaining) cooldownText;
|
||||
|
||||
const RiskStatusNotices({
|
||||
super.key,
|
||||
required this.isLoggedIn,
|
||||
required this.cooldownRemaining,
|
||||
required this.notLoggedInText,
|
||||
required this.cooldownText,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (!isLoggedIn)
|
||||
AppNotice(
|
||||
notLoggedInText,
|
||||
error: true,
|
||||
icon: Icons.warning_amber_rounded,
|
||||
),
|
||||
if (isLoggedIn && cooldownRemaining > 0)
|
||||
AppNotice(
|
||||
cooldownText(cooldownRemaining),
|
||||
icon: Icons.timer_outlined,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 功能说明卡。[danger] 用于高危中转页的红色警示卡。
|
||||
class RiskFeatureCard extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String? description;
|
||||
final Widget? child;
|
||||
final bool danger;
|
||||
|
||||
const RiskFeatureCard({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.title,
|
||||
this.description,
|
||||
this.child,
|
||||
this.danger = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final tint = danger ? theme.colorScheme.error : null;
|
||||
return AppCard(
|
||||
borderColor:
|
||||
danger ? theme.colorScheme.error.withValues(alpha: 0.4) : null,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SectionTitle(icon: icon, title: title, color: tint),
|
||||
if (description != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
description!,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
?child,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 「已拉取服务器数据」状态 + 刷新按钮。各页原来各抄一份 Row。
|
||||
class RiskFetchCard extends StatelessWidget {
|
||||
final bool hasData;
|
||||
final String statusText;
|
||||
final String loadingLabel;
|
||||
final String actionLabel;
|
||||
final String? error;
|
||||
final bool loading;
|
||||
final bool enabled;
|
||||
final VoidCallback onFetch;
|
||||
|
||||
const RiskFetchCard({
|
||||
super.key,
|
||||
required this.hasData,
|
||||
required this.statusText,
|
||||
required this.loadingLabel,
|
||||
required this.actionLabel,
|
||||
required this.loading,
|
||||
required this.onFetch,
|
||||
this.error,
|
||||
this.enabled = true,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return AppCard(
|
||||
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(
|
||||
statusText,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: hasData
|
||||
? Colors.green
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: 32,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: loading || !enabled ? null : onFetch,
|
||||
icon: loading
|
||||
? const SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.refresh, size: 16),
|
||||
label: Text(
|
||||
loading ? loadingLabel : actionLabel,
|
||||
style: theme.textTheme.labelSmall,
|
||||
),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (error != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
error!,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onErrorContainer,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 高危页底部的执行按钮。
|
||||
class RiskRunButton extends StatelessWidget {
|
||||
final bool running;
|
||||
final bool enabled;
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final String runningLabel;
|
||||
final String? disabledLabel;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
const RiskRunButton({
|
||||
super.key,
|
||||
required this.running,
|
||||
required this.enabled,
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.runningLabel,
|
||||
required this.onPressed,
|
||||
this.disabledLabel,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
onPressed: enabled && !running ? onPressed : null,
|
||||
icon: running
|
||||
? SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: theme.colorScheme.onPrimary,
|
||||
),
|
||||
)
|
||||
: Icon(icon, size: 20),
|
||||
label: Text(
|
||||
running ? runningLabel : (disabledLabel ?? label),
|
||||
),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 勾选行(解锁难度 / 万花筒动作共用)。
|
||||
class RiskCheckRow extends StatelessWidget {
|
||||
final bool value;
|
||||
final String label;
|
||||
final bool enabled;
|
||||
final ValueChanged<bool> onChanged;
|
||||
|
||||
const RiskCheckRow({
|
||||
super.key,
|
||||
required this.value,
|
||||
required this.label,
|
||||
required this.enabled,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
onTap: enabled ? () => onChanged(!value) : null,
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user