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

355 lines
11 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
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';
import '../services/rating_calculator.dart';
import '../services/title_api_service.dart';
import '../widgets/best50_poster.dart';
/// Best 50 成绩图页面:负责取数与导出,海报本身在 `widgets/best50_poster.dart`。
///
/// GetUserRatingApi 只需要 userId(Cookie 是可选的,与 Empurple 的
/// `UserRatingRequest` + `MaimaiApiClient` 一致),所以 Preview API 模式、
/// 未登录游戏服务器时同样能拉出 B50,只是拿不到 FC/AP、FS 这类详细标记。
class Best50Page extends StatefulWidget {
final int userId;
final String? cookies;
final String userName;
final int iconId;
final int playerRating;
const Best50Page({
super.key,
required this.userId,
this.cookies,
required this.userName,
required this.iconId,
required this.playerRating,
});
@override
State<Best50Page> createState() => _Best50PageState();
}
class _Best50PageState extends State<Best50Page> {
final GlobalKey _posterKey = GlobalKey();
UserRatingBean? _rating;
String? _error;
bool _loading = true;
bool _exporting = false;
bool _musicReady = false;
@override
void initState() {
super.initState();
_bootstrap();
}
@override
void didUpdateWidget(Best50Page oldWidget) {
super.didUpdateWidget(oldWidget);
// 用户可能在 preview/登录请求完成前就切到本页,cookies 到位后再补一次加载。
if (oldWidget.cookies != widget.cookies &&
(_rating == null || _error != null)) {
_bootstrap();
}
}
Future<void> _bootstrap() async {
if (!TitleServerConfigHolder().isConfigured) {
_fail(AppStrings.ticketNotConfigured);
return;
}
setState(() {
_loading = true;
_error = null;
});
try {
final service = SessionModel.instance.createTitleService(
cookies: widget.cookies,
)!;
final rating = await service.getUserRatingTyped(widget.userId);
if (!mounted) return;
setState(() {
_rating = rating;
_loading = false;
});
// 曲名/定数缺失时仍能出图(RA 会是 0),所以不当作致命错误。
await MusicDataService.instance.load();
if (!mounted) return;
setState(() => _musicReady = true);
} catch (e) {
_fail(describeApiError(e));
}
}
void _fail(String message) {
setState(() {
_loading = false;
_rating = null;
_error = message;
});
}
List<Best50CardData> _resolve(List<UserRatingItemBean> items) {
final music = MusicDataService.instance;
return items.map((item) {
final result = RatingCalculator.compute(
music.getDs(item.musicId, item.level),
item.achievementPercent,
);
return Best50CardData(
musicId: item.musicId,
level: item.musicLevel,
title: music.getTitle(item.musicId),
achievementText: item.achievementText,
comboStatus: item.comboStatus,
syncStatus: item.syncStatus,
ra: result.ra,
rate: result.rate,
);
}).toList();
}
Future<void> _export() async {
setState(() => _exporting = true);
try {
final boundary =
_posterKey.currentContext?.findRenderObject()
as RenderRepaintBoundary?;
if (boundary == null) throw Exception('海报尚未完成布局');
final image = await boundary.toImage(pixelRatio: Best50Poster.exportPixelRatio);
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
image.dispose();
if (byteData == null) throw Exception('PNG 编码失败');
final path = await exportPng(
byteData.buffer.asUint8List(),
'best50_${widget.userId}.png',
);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(AppStrings.best50Exported(path))),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(AppStrings.best50ExportFailed('$e'))),
);
} finally {
if (mounted) setState(() => _exporting = false);
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
if (_loading) {
return const Center(child: CircularProgressIndicator());
}
final rating = _rating;
if (rating == null) {
return _StateView(
message: _error ?? AppStrings.loadFailed,
onRetry: _bootstrap,
);
}
if (rating.ratingList.isEmpty && rating.newRatingList.isEmpty) {
return _StateView(
message: AppStrings.best50Empty,
onRetry: _bootstrap,
icon: Icons.bar_chart_outlined,
);
}
final best35 = _resolve(
rating.ratingList.take(Best50Poster.maxBest35).toList(),
);
final best15 = _resolve(
rating.newRatingList.take(Best50Poster.maxBest15).toList(),
);
return Scaffold(
body: Column(
children: [
responsiveBody(
context,
child: Column(
children: [
if (!_musicReady)
_NoticeBanner(
theme: theme,
text: AppStrings.best50NoMusicData,
),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: _exporting ? null : _export,
icon: _exporting
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.download, size: 20),
label: Text(
_exporting
? AppStrings.best50Exporting
: AppStrings.best50Export,
),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
),
),
],
),
),
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
// 海报要按视口整体缩放一档,进来才能看见完整一张(之前的现象是
// 「太大且缩不小」)。两处坑:
// 1. InteractiveViewer(constrained:false) 给子节点无界约束,
// 此时 FittedBox 根本不会缩放,必须先给一个有限大小的盒子。
// 2. InteractiveViewer 没有 initialScale,所以「初始就缩到位」
// 只能靠把子节点做成 contain 尺寸、minScale 固定 1.0。
// RepaintBoundary 放在 FittedBox 内侧:它自身的绘制尺寸仍是
// 1762x1850,导出分辨率不受这里缩放影响。
final fitWidth =
constraints.maxWidth / Best50Poster.canvasWidth;
final fitHeight =
constraints.maxHeight / Best50Poster.canvasHeight;
final contain = fitWidth < fitHeight ? fitWidth : fitHeight;
return Center(
child: InteractiveViewer(
constrained: false,
minScale: 1.0,
maxScale: 10.0,
boundaryMargin: const EdgeInsets.all(24),
child: SizedBox(
width: Best50Poster.canvasWidth * contain,
height: Best50Poster.canvasHeight * contain,
child: FittedBox(
fit: BoxFit.fill,
child: RepaintBoundary(
key: _posterKey,
child: Best50Poster(
userName: widget.userName,
iconId: widget.iconId,
playerRating: widget.playerRating,
best35: best35,
best15: best15,
),
),
),
),
),
);
},
),
),
],
),
);
}
}
// =============================================================================
// 页面级辅助组件(非海报部分,跟随 Ichino 现有卡片/横幅风格)
// =============================================================================
class _NoticeBanner extends StatelessWidget {
final ThemeData theme;
final String text;
const _NoticeBanner({required this.theme, required this.text});
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
margin: const EdgeInsets.fromLTRB(16, 12, 16, 0),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: theme.colorScheme.tertiaryContainer.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(10),
),
child: Text(
text,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onTertiaryContainer,
),
),
);
}
}
class _StateView extends StatelessWidget {
final String message;
final VoidCallback onRetry;
final IconData icon;
const _StateView({
required this.message,
required this.onRetry,
this.icon = Icons.error_outline,
});
@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(icon, size: 48, color: theme.colorScheme.error),
const SizedBox(height: 12),
Text(
message,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 20),
FilledButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh, size: 20),
label: const Text(AppStrings.retry),
),
],
),
),
);
}
}