Files
UnionApp/lib/pages/music/score_single.dart
spasolreisa c5c3f7c8f5 ver1.00.00
bindQR
2026-04-19 22:22:04 +08:00

1001 lines
36 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 'package:webview_flutter/webview_flutter.dart'; // 【新增】导入 webview
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;
bool _isDownloading = false;
final Map<String, List<Map<String, dynamic>>> _chartDataCache = {};
final Map<String, bool> _isLoadingChart = {};
// 【新增】用于控制 WebView 显示和加载 URL
String? _previewUrl;
late WebViewController _webViewController;
@override
void initState() {
super.initState();
_initSelectedType();
// 【新增】初始化 WebViewController
_webViewController = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setNavigationDelegate(
NavigationDelegate(
onProgress: (int progress) {
// 可选:更新加载进度
},
onPageStarted: (String url) {},
onPageFinished: (String url) {},
onHttpError: (HttpResponseError error) {},
onWebResourceError: (WebResourceError error) {},
),
);
}
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;
}
Future<void> _downloadCurrentTypeAdx() async {
if (_isDownloading) return;
if (_selectedType == null || _selectedType == 'UT') {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("仅支持下载 SD 或 DX 谱面")),
);
return;
}
setState(() {
_isDownloading = true;
});
try {
int songId = widget.song.id;
String url;
String type = _selectedType!;
if (type == 'SD') {
String idStr = songId.toString();
url = "https://cdn.godserver.cn/resource/static/adx/$idStr.adx";
} else {
int dxId = songId + 10000;
String idStr = dxId.toString();
url = "https://cdn.godserver.cn/resource/static/adx/$idStr.adx";
}
final response = await http.get(Uri.parse(url));
if (response.statusCode != 200) {
throw Exception("下载失败: HTTP ${response.statusCode}");
}
final directory = await getApplicationDocumentsDirectory();
String safeTitle = widget.song.title?.replaceAll(RegExp(r'[^\w\s\u4e00-\u9fa5]'), '_') ?? "song";
String fileName = "${safeTitle}_$type.adx";
final filePath = "${directory.path}/$fileName";
final file = File(filePath);
await file.writeAsBytes(response.bodyBytes);
final result = await OpenFile.open(filePath);
if (result.type != ResultType.done) {
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 = 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) {
// 【新增】如果存在预览 URL则显示 WebView 页面
if (_previewUrl != null) {
return Scaffold(
appBar: AppBar(
title: const Text("谱面预览"),
actions: [
IconButton(
icon: const Icon(Icons.close),
onPressed: () {
setState(() {
_previewUrl = null;
});
},
),
],
),
body: WebViewWidget(controller: _webViewController),
);
}
// 否则显示原有的详情页面
final coverUrl = _getCoverUrl(widget.song.id);
final diffs = _getCurrentDifficulties();
final availableTypes = _getAvailableTypes();
return Scaffold(
appBar: AppBar(
title: Text(widget.song.title ?? "歌曲详情"),
elevation: 2,
actions: [
if (_selectedType != 'UT' && _selectedType != null)
IconButton(
icon: _isDownloading
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.download_rounded),
tooltip: "下载 ${_selectedType} 谱面",
onPressed: _isDownloading ? null : _downloadCurrentTypeAdx,
),
],
),
// 【修改】使用 Column 布局,将固定内容和滚动内容分离
body: Column(
children: [
// 1. 固定顶部:歌曲信息卡片
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 16),
child: 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]),
),
],
),
),
],
),
),
),
),
// 2. 滚动区域:难度详情
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 12), // 注意vertical padding 可以减小或移除,因为顶部已有 padding
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
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),
],
),
),
),
],
),
);
}
// 【修改】_diffItem 方法,增加预览按钮
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);
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(
mainAxisSize: MainAxisSize.min,
children: [
if (hasUserScore)
const Icon(Icons.star_rounded, color: Colors.amber, size: 22),
const SizedBox(width: 8),
// 【新增】谱面预览按钮
IconButton(
icon: const Icon(Icons.preview_rounded, size: 22),
tooltip: "查看谱面预览",
onPressed: () {
// 构造 URL
// 规则: https://maimai.lxns.net/chart?chart_id={id}&difficulty={level}
int chartId;
if (type == 'SD') {
chartId = widget.song.id;
} else if (type == 'DX') {
chartId = widget.song.id + 10000;
} else {
// UT 情况,通常 UT 的 ID 在 diff['id'] 中,或者需要特殊处理
// 这里假设 UT 也使用类似的 ID 逻辑,如果 lxns 支持 UT 预览的话
// 如果 lxns 不支持 UT可以禁用此按钮或提示
chartId = (diff['id'] as num?)?.toInt() ?? 0;
}
final url = "https://maimai.lxns.net/chart?chart_id=$chartId&difficulty=$levelId";
// 加载 URL 并切换视图
_webViewController.loadRequest(Uri.parse(url));
setState(() {
_previewUrl = url;
});
},
),
],
),
],
),
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;
}
}