Files
Ichino/lib/pages/best50_page.dart
T
Cookiues 8dda399f76 feat: 旅行伙伴发放与编组、修复退登卡死、优化宽屏布局、加 CI
自动退出登录卡死:_performLogoutAndExit 只 pop() 一层,而解锁/收藏品页是 push
在 HomePage 之上的,退登后被弹回「风险」hub,同时 _loggingOut 仍为 true,
PopScope(canPop:false) + 返回键转圈形成死局。改为 popUntil(isFirst)。

旅行伙伴发不了:它是 ItemKind.Character = 9,与搭档 Partner = 10 是两种东西,
原先收藏品的类型列表 [1,2,3,10,11,12] 里根本没有 9。按客户端实现,角色不走
userItemList(ExportUserItems 不导出 itemKind 9),而是
upsertUserAll.userCharacterList 的 {characterId, level, awakening, useCount},
配 isNewCharacterList 一行一字符。新增发放与 5 槽 charaSlot 编组页,
含「把槽 0 复制到其他槽位」,并校验所选角色确为已拥有或本次待发放。

宽屏布局:各页只有 ConstrainedBox 限宽、没有居中容器,1440 窗口下内容贴左、
右侧大片空白。抽出 responsiveBody 统一居中;B50 预览改用按宽度适配
(InteractiveViewer constrained:false + FittedBox fitWidth),横屏下不再被
contain 缩成中间一小块,导出仍是原始 1762x1850。

CI:.github/workflows/build.yml 先跑 analyze+test 作为门槛,再产出
Windows exe、Android apk、iOS 未签名 ipa(macOS runner,无证书故不可安装)。
README 从 Flutter 模板改写为实际文档,含 path_provider/jni 与 JBR jlink
两个构建环境坑的说明。
2026-09-26 00:37:54 +08:00

346 lines
10 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/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);
// 本页在 IndexedStack 里会先于 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 = TitleApiService(
TitleServerConfigHolder().config!,
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);
} on TitleApiException catch (e) {
_fail(e.message);
} catch (e) {
_fail('$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) {
// 按视口宽度适配:横屏/桌面下海报占满宽度、竖向拖动看全图,
// 而不是被 contain 缩成中间一小块、两侧大片空白。
// minScale 再允许缩到整张可见,导出仍是原始 1762x1850。
final fitWidth =
constraints.maxWidth / Best50Poster.canvasWidth;
final scaledHeight = Best50Poster.canvasHeight * fitWidth;
final containScale = constraints.maxHeight / scaledHeight;
final minScale = containScale < 1 ? containScale : 1.0;
return InteractiveViewer(
constrained: false,
minScale: minScale,
maxScale: minScale * 6,
boundaryMargin: const EdgeInsets.all(24),
child: FittedBox(
fit: BoxFit.fitWidth,
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),
),
],
),
),
);
}
}