Files
UnionApp/lib/pages/music/score_single.dart
spasolreisa 74e47971ca 0419 0318
更新4
2026-04-19 03:18:42 +08:00

980 lines
34 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: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;
final Map<int, Map<int, dynamic>> userScoreCache;
const SongDetailPage({
super.key,
required this.song,
required this.userScoreCache,
});
@override
State<SongDetailPage> createState() => _SongDetailPageState();
}
class _SongDetailPageState extends State<SongDetailPage> {
String? _selectedType;
bool _isAliasExpanded = false;
// 用于跟踪下载状态key为 "type_levelId"
final Map<String, bool> _isDownloading = {};
final Map<String, List<Map<String, dynamic>>> _chartDataCache = {};
final Map<String, bool> _isLoadingChart = {};
@override
void initState() {
super.initState();
_initSelectedType();
}
void _initSelectedType() {
if (widget.song.sd != null && widget.song.sd!.isNotEmpty) {
_selectedType = 'SD';
} else if (widget.song.dx != null && widget.song.dx!.isNotEmpty) {
_selectedType = 'DX';
} else if (widget.song.ut != null && widget.song.ut!.isNotEmpty) {
_selectedType = 'UT';
}
}
List<String> _getAvailableTypes() {
List<String> types = [];
if (widget.song.sd != null && widget.song.sd!.isNotEmpty) types.add('SD');
if (widget.song.dx != null && widget.song.dx!.isNotEmpty) types.add('DX');
if (widget.song.ut != null && widget.song.ut!.isNotEmpty) types.add('UT');
return types;
}
List<Map<String, dynamic>> _getCurrentDifficulties() {
List<Map<String, dynamic>> all = [];
if (_selectedType == 'SD' && widget.song.sd != null) {
for (var d in widget.song.sd!.values) {
all.add({'type': 'SD', 'diff': d});
}
} else if (_selectedType == 'DX' && widget.song.dx != null) {
for (var d in widget.song.dx!.values) {
all.add({'type': 'DX', 'diff': d});
}
} else if (_selectedType == 'UT' && widget.song.ut != null) {
for (var d in widget.song.ut!.values) {
all.add({'type': 'UT', 'diff': d});
}
}
all.sort((a, b) {
int idA = a['diff']['level_id'] ?? 0;
int idB = b['diff']['level_id'] ?? 0;
return idA.compareTo(idB);
});
return all;
}
// ===================== 【新增】下载并打开 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;
}
setState(() {
_isLoadingChart[type] = true;
});
try {
final userProvider = UserProvider.instance;
final token = userProvider.token;
if (token == null || token.isEmpty) {
setState(() => _isLoadingChart[type] = false);
return;
}
int apiMusicId;
if (type == 'SD') {
apiMusicId = widget.song.id;
} else if (type == 'DX') {
apiMusicId = 10000 + widget.song.id;
} else {
setState(() => _isLoadingChart[type] = false);
return;
}
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,
'scoreRank': log.segaChartNew?.scoreRank ?? 0,
'comboStatus': log.segaChartNew?.comboStatus ?? 0,
'syncStatus': log.segaChartNew?.syncStatus ?? 0,
'deluxscoreMax': log.segaChartNew?.deluxscoreMax ?? 0,
'playCount': log.segaChartNew?.playCount ?? 0,
'level': log.segaChartNew?.level ?? 0,
}).toList();
setState(() {
_chartDataCache[type] = chartList;
_isLoadingChart[type] = false;
});
} catch (e) {
print("Load chart error: $e");
setState(() => _isLoadingChart[type] = false);
}
}
Future<void> _loadUtChartData(String cacheKey, int realId) async {
if (_chartDataCache.containsKey(cacheKey) || _isLoadingChart[cacheKey] == true) {
return;
}
setState(() => _isLoadingChart[cacheKey] = true);
try {
final userProvider = UserProvider.instance;
final token = userProvider.token;
if (token == null || token.isEmpty) {
setState(() => _isLoadingChart[cacheKey] = false);
return;
}
final logs = await UserService.getChartLog(token, [realId]);
logs.sort((a, b) => a.time.compareTo(b.time));
final chartList = logs.map((log) => {
'achievement': log.segaChartNew?.achievement ?? 0,
'time': log.time,
'scoreRank': log.segaChartNew?.scoreRank ?? 0,
'comboStatus': log.segaChartNew?.comboStatus ?? 0,
'syncStatus': log.segaChartNew?.syncStatus ?? 0,
'deluxscoreMax': log.segaChartNew?.deluxscoreMax ?? 0,
'playCount': log.segaChartNew?.playCount ?? 0,
}).toList();
setState(() {
_chartDataCache[cacheKey] = chartList;
_isLoadingChart[cacheKey] = false;
});
} catch (e) {
print("Load UT chart error: $e");
setState(() => _isLoadingChart[cacheKey] = false);
}
}
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);
final diffs = _getCurrentDifficulties();
final availableTypes = _getAvailableTypes();
return Scaffold(
appBar: AppBar(
title: Text(widget.song.title ?? "歌曲详情"),
elevation: 2,
),
body: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
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(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,
),
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]),
),
],
),
),
],
),
),
),
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)
const Center(
child: Padding(
padding: EdgeInsets.all(30),
child: Text("暂无谱面数据", style: TextStyle(color: Colors.grey)),
),
)
else
...diffs.map((item) => _diffItem(
type: item['type'],
diff: item['diff'],
)),
const SizedBox(height: 30),
],
),
),
);
}
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 designer = diff['note_designer'] ?? "-";
final notes = diff['notes'] ?? <String, dynamic>{};
final total = notes['total'] ?? 0;
final tap = notes['tap'] ?? 0;
final hold = notes['hold'] ?? 0;
final slide = notes['slide'] ?? 0;
final brk = notes['break_'] ?? 0;
int realId;
String? utTitleName;
String cacheKey = "";
if (type == 'UT') {
realId = (diff['id'] as num?)?.toInt() ?? 0;
final utTitleMap = widget.song.utTitle as Map?;
if (utTitleMap != null) {
final key = diff['id'].toString();
utTitleName = utTitleMap[key]?.toString();
}
cacheKey = "UT_$realId";
WidgetsBinding.instance.addPostFrameCallback((_) {
_loadUtChartData(cacheKey, realId);
});
} else {
realId = _getRealMusicId(type);
cacheKey = type;
WidgetsBinding.instance.addPostFrameCallback((_) {
_loadTypeChartData(type);
});
}
List<Map<String, dynamic>> chartHistory = [];
if (_chartDataCache.containsKey(cacheKey)) {
if (type == 'UT') {
chartHistory = _chartDataCache[cacheKey]!;
} else {
chartHistory = _chartDataCache[cacheKey]!
.where((e) => e['level'] == levelId)
.toList();
}
}
bool hasScoreData = chartHistory.isNotEmpty;
final score = widget.userScoreCache[realId]?[levelId];
bool hasUserScore = score != null;
String name = "";
Color color = Colors.grey;
bool isBanquet = type == 'UT';
if (isBanquet) {
color = Colors.pinkAccent;
name = utTitleName ?? "UT 宴会谱";
} else {
switch (levelId) {
case 0: name = "$type Basic"; color = Colors.green; break;
case 1: name = "$type Advanced"; color = Colors.yellow.shade700; break;
case 2: name = "$type Expert"; color = Colors.red; break;
case 3: name = "$type Master"; color = Colors.purple; break;
case 4: name = "$type Re:Master"; color = Colors.purpleAccent.shade100; break;
default: name = type;
}
}
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.symmetric(horizontal: 4, vertical: 8),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
elevation: 5,
shadowColor: color.withOpacity(0.2),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: color.withOpacity(0.15),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: color.withOpacity(0.4), width: 1.2),
),
child: Text(
isBanquet ? name : "$name (${lvValue.toStringAsFixed(1)})",
style: TextStyle(
color: color,
fontWeight: FontWeight.bold,
fontSize: 15,
),
),
),
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),
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: 14),
if (hasScoreData)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text("推分进程", style: TextStyle(fontSize: 13, color: Colors.grey)),
Text(
"最近: ${(chartHistory.last['achievement'] / 10000.0).toStringAsFixed(4)}%",
style: TextStyle(fontSize: 13, color: color, fontWeight: FontWeight.w600),
),
],
),
const SizedBox(height: 8),
ScoreProgressChart(
historyScores: chartHistory,
lineColor: color,
fillColor: color,
),
],
)
else
const Padding(
padding: EdgeInsets.symmetric(vertical: 8),
child: Text("暂无历史记录", style: TextStyle(fontSize: 13, color: Colors.grey)),
),
if (hasUserScore) ...[
const SizedBox(height: 12),
Divider(height: 1, color: Colors.grey[300]),
const SizedBox(height: 12),
_buildScoreInfo(score!,diff),
],
],
),
),
);
}
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 dxScore = score['deluxscoreMax'] ?? 0;
final playCount = score['playCount'] ?? 0;
final rating = score['rating'] ?? 0;
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: [
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])),
],
),
],
),
),
],
);
}
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;
if (ach >= 99.5) return Colors.purpleAccent;
if (ach >= 99.0) return Colors.deepPurple;
if (ach >= 98.0) return Colors.lightBlue;
if (ach >= 97.0) return Colors.blue;
return Colors.green;
}
Color _getColorByRank(String rank) {
if (rank.contains("SSS+")) return const Color(0xFFD4AF37);
if (rank.contains("SSS")) return Colors.purple;
if (rank.contains("SS+") || rank.contains("SS")) return Colors.deepPurple;
if (rank.contains("S+") || rank.contains("S")) return Colors.blue;
return Colors.green;
}
String _getCoverUrl(int musicId) {
int displayId = musicId % 10000;
if (musicId >= 16000 && musicId <= 20000) {
String idStr = displayId.toString().padLeft(6, '0');
return "https://u.mai2.link/jacket/UI_Jacket_$idStr.jpg";
} else {
return "https://cdn.godserver.cn/resource/static/mai/cover/$displayId.png";
}
}
String _getRankText(int rank) {
switch (rank) {
case 13: return "SSS+";
case 12: return "SSS";
case 11: return "SS+";
case 10: return "SS";
case 9: return "S+";
case 8: return "S";
case 7: return "AAA";
case 6: return "AA";
case 5: return "A";
case 4: return "BBB";
case 3: return "BB";
case 2: return "B";
case 1: return "C";
default: return "D";
}
}
int _getRealMusicId(String type) {
if (type == "SD") return widget.song.id;
if (type == "DX") return 10000 + widget.song.id;
return 100000 + widget.song.id;
}
}