0418 0222

更新2
This commit is contained in:
spasolreisa
2026-04-18 02:11:45 +08:00
parent fed18d264a
commit 00bd43dc7f
7 changed files with 1805 additions and 24 deletions

View File

@@ -0,0 +1,569 @@
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;
}
}