Files
Ichino/lib/widgets/account_backup_card.dart
T
Cookiues 0cb5320290 feat: 主页账号备份,并修好 B50 预览缩不小
账号备份(AccountBackupCard + AccountBackupService):
- 并发读 GetUserData/Extend/Option/Rating/Charge/Activity/MissionData +
  Character + Map(分页)+ KaleidxScope + 各 itemKind 的 GetUserItemApi,
  汇成一个 JSON 字符串可复制到剪贴板。舞里程与总游玩数据本就在
  userData 的 point/totalPoint/playCount 里。
- itemKind 15(KaleidxScopeKey)不列进回读清单:客户端 ExportUserItems
  从不输出它,下行也没有读它的路径,钥匙实际是
  userKaleidxScopeList[].isKeyFound。
- 全程只发 GetUser*Api,不碰 UpsertUserAllApi。
- 逐段容错:私服没实现某个接口时其余照样备出,失败段单独列出——
  「读到但是空的」和「没读到」在 userItemList 里按 kind 分开存,可区分。
- 取数与拼装分开,buildBackupDocument/encode 部分是纯函数,单测覆盖。

B50 预览:原因是 InteractiveViewer(constrained:false) 给孩子无界约束,
FittedBox(fit: fitWidth) 在无界下根本不缩放,海报按 1762x1850 原尺寸铺开;
而 minScale 又算成 1.0,于是既巨大又缩不动。改为给一个 contain 尺寸的
有限盒子让 FittedBox 真正缩放,minScale 固定 1.0(这个版本没有
initialScale 可用)。RepaintBoundary 留在 FittedBox 内侧,绘制尺寸仍是
1762x1850,导出分辨率不受影响,并加了断言守住这点。
2026-10-02 12:26:03 +08:00

185 lines
5.8 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show Clipboard, ClipboardData;
import '../config/strings.dart';
import '../services/account_backup_service.dart';
import '../services/title_api_service.dart';
import 'app_card.dart';
import 'app_notice.dart';
/// 账号备份:把服务器能读到的用户数据汇成一长串 JSON,可复制到剪贴板。
///
/// 只读不写——全程只发 `GetUser*Api`,不会碰 UpsertUserAllApi,所以和
/// 「风险」页那些伪造包相比是安全操作。
class AccountBackupCard extends StatefulWidget {
final int userId;
final String? cookies;
const AccountBackupCard({super.key, required this.userId, this.cookies});
@override
State<AccountBackupCard> createState() => _AccountBackupCardState();
}
class _AccountBackupCardState extends State<AccountBackupCard> {
bool _running = false;
String _progress = '';
AccountBackup? _result;
String? _error;
Future<void> _run() async {
final service = TitleApiService.fromHolder(cookies: widget.cookies);
if (service == null) {
context.showSnack(AppStrings.ticketNotConfigured);
return;
}
setState(() {
_running = true;
_error = null;
_result = null;
_progress = '';
});
try {
final backup = await AccountBackupService(service, userId: widget.userId)
.run(
onSection: (section) {
if (mounted) setState(() => _progress = section);
},
);
if (!mounted) return;
setState(() {
_result = backup;
_running = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_error = '$e';
_running = false;
});
}
}
Future<void> _copy(String text) async {
await Clipboard.setData(ClipboardData(text: text));
if (!mounted) return;
context.showSnack(AppStrings.accountBackupCopied);
}
String _sizeLabel(int bytes) {
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
return '${(bytes / 1024 / 1024).toStringAsFixed(2)} MB';
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final result = _result;
return AppCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SectionTitle(
icon: Icons.folder_zip_outlined,
title: AppStrings.accountBackupTitle,
),
const SizedBox(height: 10),
Text(
AppStrings.accountBackupDesc,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 14),
SizedBox(
width: double.infinity,
child: FilledButton.tonalIcon(
onPressed: _running ? null : _run,
icon: _running
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.download_outlined, size: 20),
label: Text(
_running
? AppStrings.accountBackupRunning(_progress)
: AppStrings.accountBackupRun,
),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 13),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
),
if (_error != null) ...[
const SizedBox(height: 12),
AppNotice(_error!, icon: Icons.error_outline, error: true),
],
if (result != null) ...[
const SizedBox(height: 12),
if (!result.isComplete) ...[
AppNotice(
AppStrings.accountBackupPartial(result.failedSections),
icon: Icons.warning_amber_rounded,
),
const SizedBox(height: 10),
],
Row(
children: [
Expanded(
child: Text(
AppStrings.accountBackupSize(_sizeLabel(result.byteLength)),
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
TextButton.icon(
onPressed: () => _copy(result.encode()),
icon: const Icon(Icons.copy_all, size: 18),
label: const Text(AppStrings.accountBackupCopy),
),
],
),
Container(
width: double.infinity,
constraints: const BoxConstraints(maxHeight: 260),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest.withValues(
alpha: 0.5,
),
borderRadius: BorderRadius.circular(8),
),
child: SingleChildScrollView(
child: SelectableText(
result.encode(),
style: theme.textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
fontSize: 11,
),
),
),
),
const SizedBox(height: 10),
Text(
AppStrings.accountBackupSecurity,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error,
),
),
],
],
),
);
}
}