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
@@ -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