1002 lines
36 KiB
Dart
1002 lines
36 KiB
Dart
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/cacheImage.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: CacheImage.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;
|
||
}
|
||
} |