569 lines
19 KiB
Dart
569 lines
19 KiB
Dart
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;
|
||
}
|
||
} |