0419 0318
更新4
This commit is contained in:
@@ -1,10 +1,15 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:open_file/open_file.dart'; // 引入打开文件的包
|
||||
import '../../../model/song_model.dart';
|
||||
import '../../../providers/user_provider.dart';
|
||||
import '../../../service/song_service.dart';
|
||||
import '../../../service/user_service.dart';
|
||||
import '../../../widgets/score_progress_chart.dart';
|
||||
import '../../tool/gradientText.dart';
|
||||
|
||||
class SongDetailPage extends StatefulWidget {
|
||||
final SongModel song;
|
||||
@@ -22,8 +27,11 @@ class SongDetailPage extends StatefulWidget {
|
||||
|
||||
class _SongDetailPageState extends State<SongDetailPage> {
|
||||
String? _selectedType;
|
||||
bool _isAliasExpanded = false;
|
||||
|
||||
// 用于跟踪下载状态,key为 "type_levelId"
|
||||
final Map<String, bool> _isDownloading = {};
|
||||
|
||||
// 缓存图表数据: Key为 "SD" / "DX" / "UT_realId"
|
||||
final Map<String, List<Map<String, dynamic>>> _chartDataCache = {};
|
||||
final Map<String, bool> _isLoadingChart = {};
|
||||
|
||||
@@ -74,9 +82,90 @@ class _SongDetailPageState extends State<SongDetailPage> {
|
||||
return all;
|
||||
}
|
||||
|
||||
// 核心:SD/DX 只加载一次,UT 每个谱面加载一次
|
||||
// ===================== 【新增】下载并打开 ADX 谱面功能 =====================
|
||||
Future<void> _downloadAndOpenAdx(String type, Map diff) async {
|
||||
int levelId = diff['level_id'] ?? 0;
|
||||
String downloadKey = "${type}_$levelId";
|
||||
|
||||
// 防止重复点击
|
||||
if (_isDownloading[downloadKey] == true) return;
|
||||
|
||||
setState(() {
|
||||
_isDownloading[downloadKey] = true;
|
||||
});
|
||||
|
||||
try {
|
||||
// 1. 构建 URL
|
||||
int songId = widget.song.id;
|
||||
String url;
|
||||
|
||||
// UT 谱面通常没有标准的 CDN adx 下载地址,这里只处理 SD 和 DX
|
||||
if (type == 'UT') {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text("UT 宴会谱暂不支持直接下载 ADX 文件")),
|
||||
);
|
||||
setState(() => _isDownloading[downloadKey] = false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (type == 'SD') {
|
||||
// SD: cdn.godserver.cn/resource/static/adx/00000/{songId}.adx
|
||||
String idStr = songId.toString().padLeft(5, '0');
|
||||
url = "https://cdn.godserver.cn/resource/static/adx/$idStr.adx";
|
||||
} else {
|
||||
// DX: cdn.godserver.cn/resource/static/adx/00000/{songId+10000}.adx
|
||||
int dxId = songId + 10000;
|
||||
String idStr = dxId.toString().padLeft(5, '0');
|
||||
url = "https://cdn.godserver.cn/resource/static/adx/$idStr.adx";
|
||||
}
|
||||
|
||||
// 2. 下载文件
|
||||
final response = await http.get(Uri.parse(url));
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception("下载失败: HTTP ${response.statusCode}");
|
||||
}
|
||||
|
||||
// 3. 获取临时目录并保存文件
|
||||
final directory = await getApplicationDocumentsDirectory();
|
||||
// 注意:iOS 上 getApplicationDocumentsDirectory 是持久化的。
|
||||
// 如果希望每次下载都清理,可以使用 getTemporaryDirectory()。
|
||||
// 这里为了稳定性,使用 DocumentsDirectory,但文件名保持唯一性。
|
||||
|
||||
// 生成文件名: SongTitle_Type_Level.adx
|
||||
String safeTitle = widget.song.title?.replaceAll(RegExp(r'[^\w\s\u4e00-\u9fa5]'), '_') ?? "song";
|
||||
String fileName = "${safeTitle}_${type}_Lv${levelId}.adx";
|
||||
|
||||
// 确保文件名不冲突,可以加时间戳或者随机数,这里简单处理
|
||||
final filePath = "${directory.path}/$fileName";
|
||||
final file = File(filePath);
|
||||
|
||||
await file.writeAsBytes(response.bodyBytes);
|
||||
|
||||
// 4. 调用系统原生打开
|
||||
// open_file 会尝试寻找能打开 .adx 的应用
|
||||
final result = await OpenFile.open(filePath);
|
||||
|
||||
if (result.type != ResultType.done) {
|
||||
// 如果没有安装能打开 adx 的应用,提示用户
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text("无法打开文件: ${result.message},请确保已安装谱面编辑器")),
|
||||
);
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
print("Download error: $e");
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text("下载出错: $e")),
|
||||
);
|
||||
} finally {
|
||||
setState(() {
|
||||
_isDownloading[downloadKey] = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadTypeChartData(String type) async {
|
||||
// 已经加载/正在加载 → 直接返回
|
||||
if (_chartDataCache.containsKey(type) || _isLoadingChart[type] == true) {
|
||||
return;
|
||||
}
|
||||
@@ -94,7 +183,6 @@ class _SongDetailPageState extends State<SongDetailPage> {
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取当前类型的 API ID
|
||||
int apiMusicId;
|
||||
if (type == 'SD') {
|
||||
apiMusicId = widget.song.id;
|
||||
@@ -105,11 +193,9 @@ class _SongDetailPageState extends State<SongDetailPage> {
|
||||
return;
|
||||
}
|
||||
|
||||
// 调用一次 API,拿到全部难度数据
|
||||
final logs = await UserService.getChartLog(token, [apiMusicId]);
|
||||
logs.sort((a, b) => a.time.compareTo(b.time));
|
||||
|
||||
// 转换数据
|
||||
final chartList = logs.map((log) => {
|
||||
'achievement': log.segaChartNew?.achievement ?? 0,
|
||||
'time': log.time,
|
||||
@@ -118,7 +204,7 @@ class _SongDetailPageState extends State<SongDetailPage> {
|
||||
'syncStatus': log.segaChartNew?.syncStatus ?? 0,
|
||||
'deluxscoreMax': log.segaChartNew?.deluxscoreMax ?? 0,
|
||||
'playCount': log.segaChartNew?.playCount ?? 0,
|
||||
'level': log.segaChartNew?.level ?? 0, // 保存难度等级用于过滤
|
||||
'level': log.segaChartNew?.level ?? 0,
|
||||
}).toList();
|
||||
|
||||
setState(() {
|
||||
@@ -131,7 +217,6 @@ class _SongDetailPageState extends State<SongDetailPage> {
|
||||
}
|
||||
}
|
||||
|
||||
// UT 单独加载
|
||||
Future<void> _loadUtChartData(String cacheKey, int realId) async {
|
||||
if (_chartDataCache.containsKey(cacheKey) || _isLoadingChart[cacheKey] == true) {
|
||||
return;
|
||||
@@ -170,6 +255,73 @@ class _SongDetailPageState extends State<SongDetailPage> {
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildAliasSection(List<String> rawAliases) {
|
||||
final uniqueAliases = rawAliases
|
||||
.where((e) => e != null && e.trim().isNotEmpty)
|
||||
.map((e) => e.trim())
|
||||
.toSet()
|
||||
.toList();
|
||||
|
||||
if (uniqueAliases.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
final displayLimit = _isAliasExpanded ? uniqueAliases.length : 3;
|
||||
final displayedAliases = uniqueAliases.take(displayLimit).toList();
|
||||
final hasMore = uniqueAliases.length > 3;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Wrap(
|
||||
spacing: 6.0,
|
||||
runSpacing: 4.0,
|
||||
children: [
|
||||
...displayedAliases.map((alias) => Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade100,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(color: Colors.grey.shade300, width: 0.5),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
child: Text(
|
||||
alias,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.grey[700],
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
)).toList(),
|
||||
],
|
||||
),
|
||||
if (hasMore)
|
||||
InkWell(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_isAliasExpanded = !_isAliasExpanded;
|
||||
});
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
_isAliasExpanded ? "收起" : "查看更多 (${uniqueAliases.length - 3})",
|
||||
style: TextStyle(fontSize: 11),
|
||||
),
|
||||
Icon(
|
||||
_isAliasExpanded ? Icons.keyboard_arrow_up : Icons.keyboard_arrow_down,
|
||||
size: 14,
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final coverUrl = _getCoverUrl(widget.song.id);
|
||||
@@ -177,129 +329,133 @@ class _SongDetailPageState extends State<SongDetailPage> {
|
||||
final availableTypes = _getAvailableTypes();
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(widget.song.title ?? "歌曲详情")),
|
||||
appBar: AppBar(
|
||||
title: Text(widget.song.title ?? "歌曲详情"),
|
||||
elevation: 2,
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 歌曲封面 + 信息
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
// 修复:这里缺少了括号
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Image.network(
|
||||
coverUrl,
|
||||
width: 120,
|
||||
height: 120,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => Container(
|
||||
width: 120,
|
||||
height: 120,
|
||||
color: Colors.grey[200],
|
||||
child: const Icon(Icons.music_note, size: 40),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
"[${widget.song.id}] ${widget.song.title}",
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
Card(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
elevation: 4,
|
||||
shadowColor: Colors.purpleAccent.withOpacity(0.2),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: Image.network(
|
||||
coverUrl,
|
||||
width: 90,
|
||||
height: 90,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => Container(
|
||||
width: 90,
|
||||
height: 90,
|
||||
color: Colors.grey[200],
|
||||
child: const Icon(Icons.music_note, size: 36),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
"艺术家:${widget.song.artist ?? '未知'}",
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
Text(
|
||||
"流派:${widget.song.genre} | 版本:${widget.song.from}",
|
||||
style: const TextStyle(fontSize: 13, color: Colors.grey),
|
||||
),
|
||||
Text(
|
||||
"BPM:${widget.song.bpm ?? '未知'}",
|
||||
style: const TextStyle(fontSize: 13, color: Colors.grey),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 难度选择器
|
||||
if (availableTypes.length > 1) ...[
|
||||
Row(
|
||||
children: [
|
||||
const Text(
|
||||
"难度详情",
|
||||
style: TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: CupertinoSlidingSegmentedControl<String>(
|
||||
groupValue: _selectedType,
|
||||
backgroundColor: Colors.grey.shade200,
|
||||
thumbColor: Theme.of(context).primaryColor.withOpacity(0.8),
|
||||
children: {
|
||||
for (var type in availableTypes)
|
||||
type: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Text(
|
||||
type,
|
||||
style: TextStyle(
|
||||
color: _selectedType == type ? Colors.white : Colors.black87,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
"[${widget.song.id}] ${widget.song.title}",
|
||||
style: const TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
},
|
||||
onValueChanged: (value) {
|
||||
if (value != null) {
|
||||
setState(() {
|
||||
_selectedType = value;
|
||||
});
|
||||
}
|
||||
},
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
"艺术家:${widget.song.artist ?? '未知'}",
|
||||
style: const TextStyle(fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
if (widget.song.albums?.isNotEmpty == true)
|
||||
_buildAliasSection(widget.song.albums!),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
"${widget.song.genre ?? ''} | ${widget.song.from ?? ''}",
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
"BPM:${widget.song.bpm ?? '未知'}",
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
const Text(
|
||||
"难度详情",
|
||||
style: TextStyle(fontSize: 17, fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
if (availableTypes.isNotEmpty) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
const Text(
|
||||
"难度详情",
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
if (availableTypes.length > 1)
|
||||
Expanded(
|
||||
child: CupertinoSlidingSegmentedControl<String>(
|
||||
groupValue: _selectedType,
|
||||
backgroundColor: Colors.grey.shade200,
|
||||
thumbColor: Theme.of(context).primaryColor,
|
||||
children: {
|
||||
for (var type in availableTypes)
|
||||
type: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8),
|
||||
child: Text(
|
||||
type,
|
||||
style: TextStyle(
|
||||
color: _selectedType == type ? Colors.white : Colors.black87,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
},
|
||||
onValueChanged: (val) {
|
||||
if (val != null) setState(() => _selectedType = val);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
|
||||
// 难度列表
|
||||
if (diffs.isEmpty)
|
||||
Center(
|
||||
const Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20.0),
|
||||
child: Text(
|
||||
"暂无 ${_selectedType ?? ''} 谱面数据",
|
||||
style: const TextStyle(color: Colors.grey),
|
||||
),
|
||||
padding: EdgeInsets.all(30),
|
||||
child: Text("暂无谱面数据", style: TextStyle(color: Colors.grey)),
|
||||
),
|
||||
)
|
||||
else
|
||||
...diffs.map((item) => _diffItem(
|
||||
type: item['type'],
|
||||
diff: item['diff'],
|
||||
)).toList(),
|
||||
)),
|
||||
|
||||
const SizedBox(height: 30),
|
||||
],
|
||||
@@ -310,8 +466,7 @@ class _SongDetailPageState extends State<SongDetailPage> {
|
||||
|
||||
Widget _diffItem({required String type, required Map diff}) {
|
||||
int levelId = diff['level_id'] ?? 0;
|
||||
final double lvValue =
|
||||
double.tryParse(diff['level_value']?.toString() ?? '') ?? 0;
|
||||
final double lvValue = double.tryParse(diff['level_value']?.toString() ?? '') ?? 0;
|
||||
final designer = diff['note_designer'] ?? "-";
|
||||
final notes = diff['notes'] ?? <String, dynamic>{};
|
||||
final total = notes['total'] ?? 0;
|
||||
@@ -323,9 +478,7 @@ class _SongDetailPageState extends State<SongDetailPage> {
|
||||
int realId;
|
||||
String? utTitleName;
|
||||
String cacheKey = "";
|
||||
bool hasScoreData = false;
|
||||
|
||||
// 加载逻辑:SD/DX 只加载一次,UT 每个加载一次
|
||||
if (type == 'UT') {
|
||||
realId = (diff['id'] as num?)?.toInt() ?? 0;
|
||||
final utTitleMap = widget.song.utTitle as Map?;
|
||||
@@ -339,31 +492,26 @@ class _SongDetailPageState extends State<SongDetailPage> {
|
||||
});
|
||||
} else {
|
||||
realId = _getRealMusicId(type);
|
||||
cacheKey = type; // SD/DX 用类型做缓存 key
|
||||
cacheKey = type;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_loadTypeChartData(type);
|
||||
});
|
||||
}
|
||||
|
||||
// 图表数据:SD/DX 自动过滤当前难度,UT 直接使用
|
||||
List<Map<String, dynamic>> chartHistory = [];
|
||||
if (_chartDataCache.containsKey(cacheKey)) {
|
||||
if (type == 'UT') {
|
||||
chartHistory = _chartDataCache[cacheKey]!;
|
||||
} else {
|
||||
// SD/DX 从全量数据中过滤当前难度
|
||||
chartHistory = _chartDataCache[cacheKey]!
|
||||
.where((e) => e['level'] == levelId)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
hasScoreData = chartHistory.isNotEmpty;
|
||||
|
||||
// 成绩信息
|
||||
bool hasScoreData = chartHistory.isNotEmpty;
|
||||
final score = widget.userScoreCache[realId]?[levelId];
|
||||
bool hasUserScore = score != null;
|
||||
|
||||
// 显示名称与颜色
|
||||
String name = "";
|
||||
Color color = Colors.grey;
|
||||
bool isBanquet = type == 'UT';
|
||||
@@ -382,11 +530,23 @@ class _SongDetailPageState extends State<SongDetailPage> {
|
||||
}
|
||||
}
|
||||
|
||||
final rating990 = _calculateRating(lvValue, 99.0);
|
||||
final rating995 = _calculateRating(lvValue, 99.5);
|
||||
final rating1000 = _calculateRating(lvValue, 100.0);
|
||||
final rating1003 = _calculateRating(lvValue, 100.3);
|
||||
final rating1005 = _calculateRating(lvValue, 100.5);
|
||||
|
||||
// 下载状态的 Key
|
||||
String downloadKey = "${type}_$levelId";
|
||||
bool isDownloading = _isDownloading[downloadKey] ?? false;
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||
elevation: 5,
|
||||
shadowColor: color.withOpacity(0.2),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -394,11 +554,11 @@ class _SongDetailPageState extends State<SongDetailPage> {
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.15),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: color.withOpacity(0.3)),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: color.withOpacity(0.4), width: 1.2),
|
||||
),
|
||||
child: Text(
|
||||
isBanquet ? name : "$name (${lvValue.toStringAsFixed(1)})",
|
||||
@@ -409,20 +569,116 @@ class _SongDetailPageState extends State<SongDetailPage> {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (hasUserScore)
|
||||
const Icon(Icons.star, color: Colors.amber, size: 20),
|
||||
Row(
|
||||
children: [
|
||||
if (hasUserScore)
|
||||
const Icon(Icons.star_rounded, color: Colors.amber, size: 22),
|
||||
|
||||
// ===================== 【新增】下载/预览按钮 =====================
|
||||
const SizedBox(width: 8),
|
||||
InkWell(
|
||||
onTap: isDownloading || type == 'UT' ? null : () {
|
||||
_downloadAndOpenAdx(type, diff);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(
|
||||
color: type == 'UT' ? Colors.grey.shade300 : Theme.of(context).primaryColor.withOpacity(0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: isDownloading
|
||||
? SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(color),
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
Icons.download_rounded,
|
||||
size: 18,
|
||||
color: type == 'UT' ? Colors.grey : color,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text("谱师:$designer", style: const TextStyle(fontSize: 14)),
|
||||
const SizedBox(height: 8),
|
||||
Text("谱师:$designer", style: const TextStyle(fontSize: 13)),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
"物量:$total | TAP:$tap HOLD:$hold SLIDE:$slide BRK:$brk",
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 12),
|
||||
|
||||
Table(
|
||||
columnWidths: const {
|
||||
0: FixedColumnWidth(40),
|
||||
1: FixedColumnWidth(40),
|
||||
2: FixedColumnWidth(40),
|
||||
3: FixedColumnWidth(40),
|
||||
4: FixedColumnWidth(40),
|
||||
},
|
||||
children: [
|
||||
TableRow(
|
||||
children: [
|
||||
_tableCell("总物量", color),
|
||||
_tableCell("TAP", color),
|
||||
_tableCell("HOLD", color),
|
||||
_tableCell("SLIDE", color),
|
||||
_tableCell("BRK", color),
|
||||
],
|
||||
),
|
||||
TableRow(
|
||||
children: [
|
||||
_tableCell(total.toString(), color, isHeader: false),
|
||||
_tableCell(tap.toString(), color, isHeader: false),
|
||||
_tableCell(hold.toString(), color, isHeader: false),
|
||||
_tableCell(slide.toString(), color, isHeader: false),
|
||||
_tableCell(brk.toString(), color, isHeader: false),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
|
||||
Table(
|
||||
columnWidths: const {
|
||||
0: FixedColumnWidth(50),
|
||||
1: FixedColumnWidth(50),
|
||||
2: FixedColumnWidth(50),
|
||||
3: FixedColumnWidth(50),
|
||||
4: FixedColumnWidth(50),
|
||||
5: FixedColumnWidth(50),
|
||||
},
|
||||
children: [
|
||||
TableRow(
|
||||
children: [
|
||||
_tableCell("完成度", color),
|
||||
_tableCell("99.0%", color),
|
||||
_tableCell("99.5%", color),
|
||||
_tableCell("100.0%", color),
|
||||
_tableCell("100.3%", color),
|
||||
_tableCell("100.5%", color),
|
||||
_tableCell("", color),
|
||||
],
|
||||
),
|
||||
TableRow(
|
||||
children: [
|
||||
_tableCell("Rating", color, isHeader: false),
|
||||
_tableCell(rating990.toStringAsFixed(2), color, isHeader: false),
|
||||
_tableCell(rating995.toStringAsFixed(2), color, isHeader: false),
|
||||
_tableCell(rating1000.toStringAsFixed(2), color, isHeader: false),
|
||||
_tableCell(rating1003.toStringAsFixed(2), color, isHeader: false),
|
||||
_tableCell(rating1005.toStringAsFixed(2), color, isHeader: false),
|
||||
_tableCell("", color, isHeader: false),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
// ✅ 修复:没有成绩直接显示文字,绝不显示加载圈
|
||||
const SizedBox(height: 14),
|
||||
|
||||
if (hasScoreData)
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -430,30 +686,32 @@ class _SongDetailPageState extends State<SongDetailPage> {
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text("推分进程", style: TextStyle(fontSize: 12, color: Colors.grey)),
|
||||
const Text("推分进程", style: TextStyle(fontSize: 13, color: Colors.grey)),
|
||||
Text(
|
||||
"最近: ${(chartHistory.last['achievement'] / 10000.0).toStringAsFixed(4)}%",
|
||||
style: TextStyle(fontSize: 12, color: color),
|
||||
style: TextStyle(fontSize: 13, color: color, fontWeight: FontWeight.w600),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
const SizedBox(height: 8),
|
||||
ScoreProgressChart(
|
||||
historyScores: chartHistory,
|
||||
lineColor: color,
|
||||
fillColor: color,
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
],
|
||||
)
|
||||
else
|
||||
const Text("暂无历史记录", style: TextStyle(fontSize: 12, color: Colors.grey)),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 8),
|
||||
child: Text("暂无历史记录", style: TextStyle(fontSize: 13, color: Colors.grey)),
|
||||
),
|
||||
|
||||
if (hasUserScore) ...[
|
||||
const SizedBox(height: 10),
|
||||
const Divider(height: 1),
|
||||
const SizedBox(height: 6),
|
||||
_buildScoreInfo(score!),
|
||||
const SizedBox(height: 12),
|
||||
Divider(height: 1, color: Colors.grey[300]),
|
||||
const SizedBox(height: 12),
|
||||
_buildScoreInfo(score!,diff),
|
||||
],
|
||||
],
|
||||
),
|
||||
@@ -461,41 +719,212 @@ class _SongDetailPageState extends State<SongDetailPage> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildScoreInfo(Map score) {
|
||||
Widget _tableCell(String text, Color color, {bool isHeader = true}) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontSize: isHeader ? 11 : 12,
|
||||
color: isHeader ? color : color.withOpacity(0.99),
|
||||
fontWeight: isHeader ? FontWeight.bold : FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static int _calculateRating(double diff, double achievementPercent) {
|
||||
double sys = 22.4;
|
||||
double ach = achievementPercent;
|
||||
|
||||
if (ach >= 100.5000) {
|
||||
return (diff * 22.512).floor();
|
||||
}
|
||||
if (ach == 100.4999) {
|
||||
sys = 22.2;
|
||||
} else if (ach >= 100.0000) {
|
||||
sys = 21.6;
|
||||
} else if (ach == 99.9999) {
|
||||
sys = 21.4;
|
||||
} else if (ach >= 99.5000) {
|
||||
sys = 21.1;
|
||||
} else if (ach >= 99.0000) {
|
||||
sys = 20.8;
|
||||
} else if (ach >= 98.0000) {
|
||||
sys = 20.3;
|
||||
} else if (ach >= 97.0000) {
|
||||
sys = 20.0;
|
||||
} else if (ach >= 94.0000) {
|
||||
sys = 16.8;
|
||||
} else if (ach >= 90.0000) {
|
||||
sys = 15.2;
|
||||
} else if (ach >= 80.0000) {
|
||||
sys = 13.6;
|
||||
} else if (ach >= 75.0000) {
|
||||
sys = 12.0;
|
||||
} else if (ach >= 70.0000) {
|
||||
sys = 11.2;
|
||||
} else if (ach >= 60.0000) {
|
||||
sys = 9.6;
|
||||
} else if (ach >= 50.0000) {
|
||||
sys = 8.0;
|
||||
} else {
|
||||
sys = 0.0;
|
||||
}
|
||||
|
||||
if (sys == 0.0) return 0;
|
||||
return (diff * sys * ach / 100).floor();
|
||||
}
|
||||
|
||||
Widget _buildScoreInfo(Map<String, dynamic> score, Map diff) {
|
||||
final ach = (score['achievement'] ?? 0) / 10000;
|
||||
final rank = _getRankText(score['scoreRank'] ?? 0);
|
||||
final combo = _comboText(score['comboStatus'] ?? 0);
|
||||
final sync = _syncText(score['syncStatus'] ?? 0);
|
||||
final dxScore = score['deluxscoreMax'] ?? 0;
|
||||
final playCount = score['playCount'] ?? 0;
|
||||
final rating = score['rating'] ?? 0;
|
||||
|
||||
return Column(
|
||||
int comboStatus = score['comboStatus'] ?? 0;
|
||||
int syncStatus = score['syncStatus'] ?? 0;
|
||||
|
||||
Color achColor = _getColorByAchievement(ach);
|
||||
Color rankColor = _getColorByRank(rank);
|
||||
|
||||
int totalNotes = diff['notes']['total'] ?? 0;
|
||||
int allDx = totalNotes * 3;
|
||||
double perc = allDx > 0 ? dxScore / allDx : 0.0;
|
||||
|
||||
String? comboIconPath;
|
||||
switch (comboStatus) {
|
||||
case 1: comboIconPath = "images/UI_MSS_MBase_Icon_FC.png"; break;
|
||||
case 2: comboIconPath = "images/UI_MSS_MBase_Icon_FCp.png"; break;
|
||||
case 3: comboIconPath = "images/UI_MSS_MBase_Icon_AP.png"; break;
|
||||
case 4: comboIconPath = "images/UI_MSS_MBase_Icon_APp.png"; break;
|
||||
}
|
||||
|
||||
String? syncIconPath;
|
||||
switch (syncStatus) {
|
||||
case 1: syncIconPath = "images/UI_MSS_MBase_Icon_FS.png"; break;
|
||||
case 2: syncIconPath = "images/UI_MSS_MBase_Icon_FSp.png"; break;
|
||||
case 3: syncIconPath = "images/UI_MSS_MBase_Icon_FSD.png"; break;
|
||||
case 4: syncIconPath = "images/UI_MSS_MBase_Icon_FSDp.png"; break;
|
||||
case 5: syncIconPath = "images/UI_MSS_MBase_Icon_Sync.png"; break;
|
||||
}
|
||||
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text("你的成绩:",
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13)),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Text("达成率:${ach.toStringAsFixed(4)}%",
|
||||
style: TextStyle(
|
||||
color: _getColorByAchievement(ach),
|
||||
fontWeight: FontWeight.bold)),
|
||||
const Spacer(),
|
||||
Text("评级:$rank",
|
||||
style: TextStyle(
|
||||
color: _getColorByRank(rank), fontWeight: FontWeight.bold)),
|
||||
],
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Row(
|
||||
children: [
|
||||
Icon(Icons.emoji_events_rounded, color: Colors.amber, size: 18),
|
||||
SizedBox(width: 4),
|
||||
Text(
|
||||
"你的成绩",
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 15,
|
||||
height: 1.1,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
GradientText(
|
||||
data: "${ach.toStringAsFixed(4)}%",
|
||||
style: const TextStyle(fontSize: 30, fontWeight: FontWeight.bold),
|
||||
gradientLayers: [
|
||||
GradientLayer(
|
||||
gradient: const LinearGradient(
|
||||
colors: [Colors.pinkAccent, Colors.blue],
|
||||
),
|
||||
blendMode: BlendMode.srcIn,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_buildTag(rank, rankColor),
|
||||
if (comboIconPath != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 2),
|
||||
child: Image.asset(comboIconPath, width: 40, height: 40),
|
||||
),
|
||||
if (syncIconPath != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 4),
|
||||
child: Image.asset(syncIconPath, width: 40, height: 40),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.score_rounded, size: 14, color: Colors.blueGrey),
|
||||
const SizedBox(width: 4),
|
||||
Text("DX:$dxScore / $allDx", style: TextStyle(fontSize: 13, color: Colors.grey[700])),
|
||||
const SizedBox(width: 10),
|
||||
if (perc >= 0.85)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 4),
|
||||
child: _getDxStarIcon(perc),
|
||||
),
|
||||
const Spacer(),
|
||||
Icon(Icons.star_rate_rounded, size: 14, color: Colors.amber),
|
||||
const SizedBox(width: 3),
|
||||
Text("Rating:$rating", style: TextStyle(fontSize: 12, color: Colors.grey[700], fontWeight: FontWeight.w500)),
|
||||
const SizedBox(width: 10),
|
||||
Icon(Icons.play_circle_outline_rounded, size: 14, color: Colors.grey[600]),
|
||||
const SizedBox(width: 3),
|
||||
Text("$playCount 次", style: TextStyle(fontSize: 12, color: Colors.grey[600])),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text("Combo:$combo | Sync:$sync",
|
||||
style: const TextStyle(fontSize: 12, color: Colors.blueGrey)),
|
||||
Text("DX分数:$dxScore | 游玩次数:$playCount",
|
||||
style: const TextStyle(fontSize: 12, color: Colors.grey)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _getDxStarIcon(double perc) {
|
||||
String assetPath;
|
||||
if (perc >= 0.97) {
|
||||
assetPath = "images/UI_GAM_Gauge_DXScoreIcon_05.png";
|
||||
} else if (perc >= 0.95) {
|
||||
assetPath = "images/UI_GAM_Gauge_DXScoreIcon_04.png";
|
||||
} else if (perc >= 0.93) {
|
||||
assetPath = "images/UI_GAM_Gauge_DXScoreIcon_03.png";
|
||||
} else if (perc >= 0.90) {
|
||||
assetPath = "images/UI_GAM_Gauge_DXScoreIcon_02.png";
|
||||
} else if (perc >= 0.85) {
|
||||
assetPath = "images/UI_GAM_Gauge_DXScoreIcon_01.png";
|
||||
} else {
|
||||
return const SizedBox();
|
||||
}
|
||||
return Image.asset(assetPath, width: 30, height: 30, fit: BoxFit.contain);
|
||||
}
|
||||
|
||||
Widget _buildTag(String text, Color color) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.15),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: color.withOpacity(0.5), width: 1.2),
|
||||
),
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(color: color, fontWeight: FontWeight.bold, fontSize: 14),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color _getColorByAchievement(double ach) {
|
||||
if (ach >= 100.5) return const Color(0xFFD4AF37);
|
||||
if (ach >= 100.0) return Colors.purple;
|
||||
@@ -507,9 +936,10 @@ class _SongDetailPageState extends State<SongDetailPage> {
|
||||
}
|
||||
|
||||
Color _getColorByRank(String rank) {
|
||||
if (rank.contains("SSS+")) return const Color(0xFFD4AF37);
|
||||
if (rank.contains("SSS")) return Colors.purple;
|
||||
if (rank.contains("SS")) return Colors.deepPurple;
|
||||
if (rank.contains("S")) return Colors.blue;
|
||||
if (rank.contains("SS+") || rank.contains("SS")) return Colors.deepPurple;
|
||||
if (rank.contains("S+") || rank.contains("S")) return Colors.blue;
|
||||
return Colors.green;
|
||||
}
|
||||
|
||||
@@ -542,26 +972,6 @@ class _SongDetailPageState extends State<SongDetailPage> {
|
||||
}
|
||||
}
|
||||
|
||||
String _comboText(int s) {
|
||||
switch (s) {
|
||||
case 1: return "FC";
|
||||
case 2: return "FC+";
|
||||
case 3: return "AP";
|
||||
case 4: return "AP+";
|
||||
default: return "无";
|
||||
}
|
||||
}
|
||||
|
||||
String _syncText(int s) {
|
||||
switch (s) {
|
||||
case 1: return "FS";
|
||||
case 2: return "FS+";
|
||||
case 3: return "FDX";
|
||||
case 4: return "FDX+";
|
||||
default: return "无";
|
||||
}
|
||||
}
|
||||
|
||||
int _getRealMusicId(String type) {
|
||||
if (type == "SD") return widget.song.id;
|
||||
if (type == "DX") return 10000 + widget.song.id;
|
||||
|
||||
Reference in New Issue
Block a user