Files
UnionApp/lib/pages/music/score_single.dart
spasolreisa 00bd43dc7f 0418 0222
更新2
2026-04-18 02:11:45 +08:00

569 lines
19 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 'package:flutter/cupertino.dart';
import 'package:flutter/material.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';
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;
// 缓存图表数据: Key为 "SD" / "DX" / "UT_realId"
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;
}
// 核心SD/DX 只加载一次UT 每个谱面加载一次
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;
}
// 获取当前类型的 API ID
int apiMusicId;
if (type == 'SD') {
apiMusicId = widget.song.id;
} else if (type == 'DX') {
apiMusicId = 10000 + widget.song.id;
} else {
setState(() => _isLoadingChart[type] = false);
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,
'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);
}
}
// UT 单独加载
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);
}
}
@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 ?? "歌曲详情")),
body: SingleChildScrollView(
padding: const EdgeInsets.all(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,
),
),
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,
),
),
),
},
onValueChanged: (value) {
if (value != null) {
setState(() {
_selectedType = value;
});
}
},
),
),
],
),
] else ...[
const Text(
"难度详情",
style: TextStyle(fontSize: 17, fontWeight: FontWeight.bold),
),
],
const SizedBox(height: 12),
// 难度列表
if (diffs.isEmpty)
Center(
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Text(
"暂无 ${_selectedType ?? ''} 谱面数据",
style: const TextStyle(color: Colors.grey),
),
),
)
else
...diffs.map((item) => _diffItem(
type: item['type'],
diff: item['diff'],
)).toList(),
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 = "";
bool hasScoreData = false;
// 加载逻辑SD/DX 只加载一次UT 每个加载一次
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; // SD/DX 用类型做缓存 key
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;
// 成绩信息
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;
}
}
return Card(
margin: const EdgeInsets.only(bottom: 10),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: color.withOpacity(0.15),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: color.withOpacity(0.3)),
),
child: Text(
isBanquet ? name : "$name (${lvValue.toStringAsFixed(1)})",
style: TextStyle(
color: color,
fontWeight: FontWeight.bold,
fontSize: 15,
),
),
),
if (hasUserScore)
const Icon(Icons.star, color: Colors.amber, size: 20),
],
),
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),
),
const SizedBox(height: 12),
// ✅ 修复:没有成绩直接显示文字,绝不显示加载圈
if (hasScoreData)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text("推分进程", style: TextStyle(fontSize: 12, color: Colors.grey)),
Text(
"最近: ${(chartHistory.last['achievement'] / 10000.0).toStringAsFixed(4)}%",
style: TextStyle(fontSize: 12, color: color),
),
],
),
const SizedBox(height: 4),
ScoreProgressChart(
historyScores: chartHistory,
lineColor: color,
fillColor: color,
),
],
)
else
const Text("暂无历史记录", style: TextStyle(fontSize: 12, color: Colors.grey)),
if (hasUserScore) ...[
const SizedBox(height: 10),
const Divider(height: 1),
const SizedBox(height: 6),
_buildScoreInfo(score!),
],
],
),
),
);
}
Widget _buildScoreInfo(Map score) {
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;
return Column(
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)),
],
),
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)),
],
);
}
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 Colors.purple;
if (rank.contains("SS")) return Colors.deepPurple;
if (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";
}
}
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;
return 100000 + widget.song.id;
}
}