0419 0318

更新4
This commit is contained in:
spasolreisa
2026-04-19 03:18:42 +08:00
parent d4bbc424c6
commit 74e47971ca
25 changed files with 1221 additions and 371 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 948 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

View File

@@ -592,7 +592,7 @@ class _RecommendedSongsSection extends StatelessWidget {
} }
return SizedBox( return SizedBox(
height: 200, height: 220,
child: ListView.builder( child: ListView.builder(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
itemCount: songs.length, itemCount: songs.length,
@@ -664,7 +664,7 @@ class _SongItemCard extends StatelessWidget {
ClipRRect( ClipRRect(
borderRadius: const BorderRadius.vertical(top: Radius.circular(12)), borderRadius: const BorderRadius.vertical(top: Radius.circular(12)),
child: SizedBox( child: SizedBox(
height: 120, height: 140,
width: double.infinity, width: double.infinity,
child: Image.network( child: Image.network(
_getCoverUrl(song.id), _getCoverUrl(song.id),

View File

@@ -24,8 +24,11 @@ class _MusicPageState extends State<MusicPage> with SingleTickerProviderStateMix
// 筛选 // 筛选
String _searchQuery = ''; String _searchQuery = '';
int? _filterLevelType; int? _filterLevelType;
String? _filterFromVersion;
String? _filterGenre; // --- 修改点 1: 将单选 String? 改为多选 Set<String> ---
Set<String> _filterVersions = {};
Set<String> _filterGenres = {};
double? _selectedMinLevel; double? _selectedMinLevel;
double? _selectedMaxLevel; double? _selectedMaxLevel;
bool _isAdvancedFilterExpanded = false; bool _isAdvancedFilterExpanded = false;
@@ -99,6 +102,8 @@ class _MusicPageState extends State<MusicPage> with SingleTickerProviderStateMix
_allSongs = uniqueSongs.values.toList(); _allSongs = uniqueSongs.values.toList();
_allSongs.sort((a, b) => (b.players ?? 0).compareTo(a.players ?? 0)); _allSongs.sort((a, b) => (b.players ?? 0).compareTo(a.players ?? 0));
_displaySongs = List.from(_allSongs); _displaySongs = List.from(_allSongs);
// 更新可用选项
_availableVersions = songs.map((s) => s.from).where((v) => v.isNotEmpty).toSet(); _availableVersions = songs.map((s) => s.from).where((v) => v.isNotEmpty).toSet();
_availableGenres = songs.map((s) => s.genre).where((g) => g.isNotEmpty).toSet(); _availableGenres = songs.map((s) => s.genre).where((g) => g.isNotEmpty).toSet();
@@ -110,7 +115,7 @@ class _MusicPageState extends State<MusicPage> with SingleTickerProviderStateMix
} }
} }
// 加载用户成绩 —— ✅ 修复 1直接用完整ID存储 // 加载用户成绩
Future<void> _loadUserScores() async { Future<void> _loadUserScores() async {
try { try {
final userProvider = UserProvider.instance; final userProvider = UserProvider.instance;
@@ -171,8 +176,12 @@ class _MusicPageState extends State<MusicPage> with SingleTickerProviderStateMix
if (!match) return false; if (!match) return false;
} }
if (_filterFromVersion != null && song.from != _filterFromVersion) return false; // --- 修改点 2: 多选过滤逻辑 ---
if (_filterGenre != null && song.genre != _filterGenre) return false; // 如果选择了版本,歌曲版本必须在选中集合中
if (_filterVersions.isNotEmpty && !_filterVersions.contains(song.from)) return false;
// 如果选择了流派,歌曲流派必须在选中集合中
if (_filterGenres.isNotEmpty && !_filterGenres.contains(song.genre)) return false;
if (_filterLevelType != null) { if (_filterLevelType != null) {
bool hasLevel = false; bool hasLevel = false;
@@ -248,8 +257,8 @@ class _MusicPageState extends State<MusicPage> with SingleTickerProviderStateMix
setState(() { setState(() {
_searchQuery = ''; _searchQuery = '';
_filterLevelType = null; _filterLevelType = null;
_filterFromVersion = null; _filterVersions.clear(); // 清空集合
_filterGenre = null; _filterGenres.clear(); // 清空集合
_selectedMinLevel = null; _selectedMinLevel = null;
_selectedMaxLevel = null; _selectedMaxLevel = null;
}); });
@@ -359,6 +368,123 @@ class _MusicPageState extends State<MusicPage> with SingleTickerProviderStateMix
); );
} }
// --- 新增:多选 Chip 构建器 ---
Widget _buildMultiSelectChip({
required String label,
required Set<String> selectedValues,
required Set<String> allOptions,
required Function(Set<String>) onSelectionChanged,
}) {
return InkWell(
onTap: () {
_showMultiSelectDialog(
context,
title: label,
allOptions: allOptions.toList()..sort(),
selectedValues: selectedValues,
onConfirm: (newSelection) {
setState(() {
onSelectionChanged(newSelection);
});
_applyFilters();
},
);
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade400),
borderRadius: BorderRadius.circular(4),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: const TextStyle(fontWeight: FontWeight.bold)),
Flexible(
child: Text(
selectedValues.isEmpty ? "全部" : "已选 ${selectedValues.length}",
style: TextStyle(color: Colors.grey[600], fontSize: 12),
overflow: TextOverflow.ellipsis,
),
),
const Icon(Icons.arrow_drop_down, color: Colors.grey),
],
),
),
);
}
void _showMultiSelectDialog(
BuildContext context, {
required String title,
required List<String> allOptions,
required Set<String> selectedValues,
required Function(Set<String>) onConfirm,
}) {
// 临时存储选择状态
final tempSelection = Set<String>.from(selectedValues);
showDialog(
context: context,
builder: (ctx) {
return StatefulBuilder(
builder: (context, setDialogState) {
return AlertDialog(
title: Text(title),
content: SizedBox(
width: double.maxFinite,
child: ListView.builder(
shrinkWrap: true,
itemCount: allOptions.length,
itemBuilder: (context, index) {
final option = allOptions[index];
final isSelected = tempSelection.contains(option);
return CheckboxListTile(
title: Text(option),
value: isSelected,
onChanged: (bool? value) {
setDialogState(() {
if (value == true) {
tempSelection.add(option);
} else {
tempSelection.remove(option);
}
});
},
controlAffinity: ListTileControlAffinity.leading,
dense: true,
);
},
),
),
actions: [
TextButton(
onPressed: () {
setDialogState(() {
tempSelection.clear();
});
},
child: const Text("清空"),
),
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text("取消"),
),
ElevatedButton(
onPressed: () {
onConfirm(tempSelection);
Navigator.pop(ctx);
},
child: const Text("确定"),
),
],
);
},
);
},
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@@ -460,34 +586,27 @@ class _MusicPageState extends State<MusicPage> with SingleTickerProviderStateMix
decoration: BoxDecoration(color: Colors.grey.withAlpha(20), border: Border(top: BorderSide(color: Colors.grey.shade300))), decoration: BoxDecoration(color: Colors.grey.withAlpha(20), border: Border(top: BorderSide(color: Colors.grey.shade300))),
child: Column( child: Column(
children: [ children: [
// --- 修改点 3: UI 替换为多选 Chip ---
Row( Row(
children: [ children: [
Expanded( Expanded(
child: _buildDropdown( child: _buildMultiSelectChip(
label: "版本", label: "版本",
value: _filterFromVersion, selectedValues: _filterVersions,
items: [ allOptions: _availableVersions,
const DropdownMenuItem(value: null, child: Text("全部")), onSelectionChanged: (newSet) {
..._availableVersions.map((v) => DropdownMenuItem(value: v, child: Text(v))).toList() _filterVersions = newSet;
],
onChanged: (v) {
setState(() => _filterFromVersion = v);
_applyFilters();
}, },
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: _buildDropdown( child: _buildMultiSelectChip(
label: "流派", label: "流派",
value: _filterGenre, selectedValues: _filterGenres,
items: [ allOptions: _availableGenres,
const DropdownMenuItem(value: null, child: Text("全部")), onSelectionChanged: (newSet) {
..._availableGenres.map((g) => DropdownMenuItem(value: g, child: Text(g))).toList() _filterGenres = newSet;
],
onChanged: (v) {
setState(() => _filterGenre = v);
_applyFilters();
}, },
), ),
), ),
@@ -517,10 +636,12 @@ class _MusicPageState extends State<MusicPage> with SingleTickerProviderStateMix
), ),
); );
} }
String getLeftEllipsisText(String text, int maxLength) { String getLeftEllipsisText(String text, int maxLength) {
if (text.length <= maxLength) return text; if (text.length <= maxLength) return text;
return '...${text.substring(text.length - maxLength + 3)}'; // +3 是因为 "..." 占3个字符 return '...${text.substring(text.length - maxLength + 3)}'; // +3 是因为 "..." 占3个字符
} }
Widget _buildDropdown({required String label, required dynamic value, required List<DropdownMenuItem> items, required ValueChanged onChanged}) { Widget _buildDropdown({required String label, required dynamic value, required List<DropdownMenuItem> items, required ValueChanged onChanged}) {
return DropdownButtonFormField( return DropdownButtonFormField(
value: value, value: value,
@@ -531,7 +652,13 @@ class _MusicPageState extends State<MusicPage> with SingleTickerProviderStateMix
); );
} }
bool _hasFilters() => _searchQuery.isNotEmpty || _filterLevelType != null || _filterFromVersion != null || _filterGenre != null || _selectedMinLevel != null || _selectedMaxLevel != null; bool _hasFilters() =>
_searchQuery.isNotEmpty ||
_filterLevelType != null ||
_filterVersions.isNotEmpty || // 修改判断逻辑
_filterGenres.isNotEmpty || // 修改判断逻辑
_selectedMinLevel != null ||
_selectedMaxLevel != null;
Widget _buildBody() { Widget _buildBody() {
if (_isLoading) return const Center(child: CircularProgressIndicator()); if (_isLoading) return const Center(child: CircularProgressIndicator());
@@ -558,33 +685,19 @@ class _MusicPageState extends State<MusicPage> with SingleTickerProviderStateMix
if (song.ut != null && _userScoreCache.containsKey(100000 + song.id)) hasScore = true; if (song.ut != null && _userScoreCache.containsKey(100000 + song.id)) hasScore = true;
// --- 新增:处理别名逻辑 --- // --- 新增:处理别名逻辑 ---
// 1. 获取原始列表
List<String> rawAliases = song.albums ?? []; List<String> rawAliases = song.albums ?? [];
// 2. 去重 (保留插入顺序可以使用 LinkedHashSet或者简单转为 Set 再转 List)
// 注意Set.from 可能会打乱顺序,如果顺序不重要可以直接用。
// 如果希望保持原顺序去重:
final seen = <String>{}; final seen = <String>{};
final uniqueAliases = rawAliases.where((alias) => seen.add(alias)).toList(); final uniqueAliases = rawAliases.where((alias) => seen.add(alias)).toList();
// 3. 过滤掉与标题或艺术家完全相同的重复项(可选,为了更整洁)
final filteredAliases = uniqueAliases.where((alias) => final filteredAliases = uniqueAliases.where((alias) =>
alias != song.title && alias != song.title &&
alias != song.artist alias != song.artist
).toList(); ).toList();
// 4. 拼接字符串,如果太长则截断
// 我们设定一个最大显示长度或者最大行数,这里采用“尝试放入尽可能多的词,直到超过一定长度”的策略
String aliasDisplayText = ""; String aliasDisplayText = "";
if (filteredAliases.isNotEmpty) { if (filteredAliases.isNotEmpty) {
// 尝试加入前 N 个别名,总长度控制在合理范围,例如 60-80 个字符,或者固定个数如 5-8 个
// 这里采用固定个数策略,因为每个词长度不一,固定个数更容易预测高度
int maxAliasCount = 6; int maxAliasCount = 6;
List<String> displayList = filteredAliases.take(maxAliasCount).toList(); List<String> displayList = filteredAliases.take(maxAliasCount).toList();
aliasDisplayText = displayList.join(" · "); aliasDisplayText = displayList.join(" · ");
// 如果还有更多未显示的,加上省略提示
if (filteredAliases.length > maxAliasCount) { if (filteredAliases.length > maxAliasCount) {
aliasDisplayText += " ..."; aliasDisplayText += " ...";
} }
@@ -632,12 +745,10 @@ class _MusicPageState extends State<MusicPage> with SingleTickerProviderStateMix
), ),
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
// 这一行id + 左侧标签
Row( Row(
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
// 标签区域cn / jp / m2l / long
if (song.cn == true) if (song.cn == true)
_buildTag( _buildTag(
"CN", "CN",
@@ -669,8 +780,6 @@ class _MusicPageState extends State<MusicPage> with SingleTickerProviderStateMix
), ),
), ),
// id 文本
Text( Text(
" ${song.id}", " ${song.id}",
style: const TextStyle( style: const TextStyle(
@@ -703,7 +812,6 @@ class _MusicPageState extends State<MusicPage> with SingleTickerProviderStateMix
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
// 关键:让内容在垂直方向上尽量分布,或者使用 MainAxisSize.min 配合 Spacer
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text( Text(
@@ -725,13 +833,12 @@ class _MusicPageState extends State<MusicPage> with SingleTickerProviderStateMix
const SizedBox(height: 6), const SizedBox(height: 6),
LayoutBuilder( LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
// 可选根据剩余宽度动态决定显示多少行这里简单处理为最多2行
return Text( return Text(
aliasDisplayText, aliasDisplayText,
style: TextStyle( style: TextStyle(
fontSize: 10, fontSize: 10,
color: Colors.grey[500], color: Colors.grey[500],
height: 1.2, // 紧凑行高 height: 1.2,
), ),
maxLines: 5, maxLines: 5,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
@@ -757,16 +864,11 @@ class _MusicPageState extends State<MusicPage> with SingleTickerProviderStateMix
} }
Widget _buildTag( Widget _buildTag(
String text, { String text, {
// 背景:纯色 或 渐变 二选一
Color? backgroundColor, Color? backgroundColor,
Gradient? gradient, Gradient? gradient,
// 阴影配置
Color? shadowColor, Color? shadowColor,
double shadowBlurRadius = 2, double shadowBlurRadius = 2,
Offset shadowOffset = const Offset(0, 1), Offset shadowOffset = const Offset(0, 1),
// 可选样式扩展
double borderRadius = 2, double borderRadius = 2,
double fontSize = 8, double fontSize = 8,
Color textColor = Colors.white, Color textColor = Colors.white,
@@ -775,11 +877,9 @@ class _MusicPageState extends State<MusicPage> with SingleTickerProviderStateMix
margin: const EdgeInsets.only(right: 3), margin: const EdgeInsets.only(right: 3),
padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 1), padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 1),
decoration: BoxDecoration( decoration: BoxDecoration(
// 优先使用渐变,没有渐变则使用纯色
color: gradient == null ? (backgroundColor ?? Colors.blueAccent) : null, color: gradient == null ? (backgroundColor ?? Colors.blueAccent) : null,
gradient: gradient, gradient: gradient,
borderRadius: BorderRadius.circular(borderRadius), borderRadius: BorderRadius.circular(borderRadius),
// 阴影:只有传入 shadowColor 才显示
boxShadow: shadowColor != null boxShadow: shadowColor != null
? [ ? [
BoxShadow( BoxShadow(
@@ -802,8 +902,6 @@ class _MusicPageState extends State<MusicPage> with SingleTickerProviderStateMix
} }
List<Widget> _getDifficultyChipsByType(SongModel song) { List<Widget> _getDifficultyChipsByType(SongModel song) {
final diffs = _getAllDifficultiesWithType(song); final diffs = _getAllDifficultiesWithType(song);
// 按 type 分组SD / DX / UT
final Map<String, List<dynamic>> typeGroups = {}; final Map<String, List<dynamic>> typeGroups = {};
for (var item in diffs) { for (var item in diffs) {
final type = item['type'] as String; final type = item['type'] as String;
@@ -811,7 +909,6 @@ class _MusicPageState extends State<MusicPage> with SingleTickerProviderStateMix
typeGroups[type]!.add(item); typeGroups[type]!.add(item);
} }
// 固定顺序SD → DX → UT
final order = ['SD', 'DX', 'UT']; final order = ['SD', 'DX', 'UT'];
List<Widget> rows = []; List<Widget> rows = [];
@@ -819,7 +916,6 @@ class _MusicPageState extends State<MusicPage> with SingleTickerProviderStateMix
final items = typeGroups[type]; final items = typeGroups[type];
if (items == null || items.isEmpty) continue; if (items == null || items.isEmpty) continue;
// 每一个类型 = 一行 Wrap
final row = Wrap( final row = Wrap(
spacing: 6, spacing: 6,
runSpacing: 4, runSpacing: 4,
@@ -837,8 +933,6 @@ class _MusicPageState extends State<MusicPage> with SingleTickerProviderStateMix
if (isBanquet) { if (isBanquet) {
color = Colors.pinkAccent; color = Colors.pinkAccent;
// 多UT按ID显示对应名称
if (type == 'UT') { if (type == 'UT') {
final utTitleMap = song.utTitle as Map?; final utTitleMap = song.utTitle as Map?;
if (utTitleMap != null && utTitleMap.isNotEmpty) { if (utTitleMap != null && utTitleMap.isNotEmpty) {
@@ -882,7 +976,7 @@ class _MusicPageState extends State<MusicPage> with SingleTickerProviderStateMix
} }
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 1),
decoration: BoxDecoration( decoration: BoxDecoration(
color: color.withOpacity(hasScore ? 0.25 : 0.1), color: color.withOpacity(hasScore ? 0.25 : 0.1),
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
@@ -892,7 +986,7 @@ class _MusicPageState extends State<MusicPage> with SingleTickerProviderStateMix
isBanquet ? label : "$label ${lvValue.toStringAsFixed(1)}", isBanquet ? label : "$label ${lvValue.toStringAsFixed(1)}",
style: TextStyle( style: TextStyle(
color: color, color: color,
fontSize: 10, fontSize: 9,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
@@ -916,4 +1010,4 @@ class _MusicPageState extends State<MusicPage> with SingleTickerProviderStateMix
return "https://cdn.godserver.cn/resource/static/mai/cover/$displayId.png"; return "https://cdn.godserver.cn/resource/static/mai/cover/$displayId.png";
} }
} }
} }

View File

@@ -1,10 +1,15 @@
import 'dart:io';
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:flutter/material.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 '../../../model/song_model.dart'; import '../../../model/song_model.dart';
import '../../../providers/user_provider.dart'; import '../../../providers/user_provider.dart';
import '../../../service/song_service.dart'; import '../../../service/song_service.dart';
import '../../../service/user_service.dart'; import '../../../service/user_service.dart';
import '../../../widgets/score_progress_chart.dart'; import '../../../widgets/score_progress_chart.dart';
import '../../tool/gradientText.dart';
class SongDetailPage extends StatefulWidget { class SongDetailPage extends StatefulWidget {
final SongModel song; final SongModel song;
@@ -22,8 +27,11 @@ class SongDetailPage extends StatefulWidget {
class _SongDetailPageState extends State<SongDetailPage> { class _SongDetailPageState extends State<SongDetailPage> {
String? _selectedType; String? _selectedType;
bool _isAliasExpanded = false;
// 用于跟踪下载状态key为 "type_levelId"
final Map<String, bool> _isDownloading = {};
// 缓存图表数据: Key为 "SD" / "DX" / "UT_realId"
final Map<String, List<Map<String, dynamic>>> _chartDataCache = {}; final Map<String, List<Map<String, dynamic>>> _chartDataCache = {};
final Map<String, bool> _isLoadingChart = {}; final Map<String, bool> _isLoadingChart = {};
@@ -74,9 +82,90 @@ class _SongDetailPageState extends State<SongDetailPage> {
return all; return all;
} }
// 核心SD/DX 只加载一次UT 每个谱面加载一次 // ===================== 【新增】下载并打开 ADX 谱面功能 =====================
Future<void> _downloadAndOpenAdx(String type, Map diff) async {
int levelId = diff['level_id'] ?? 0;
String downloadKey = "${type}_$levelId";
// 防止重复点击
if (_isDownloading[downloadKey] == true) return;
setState(() {
_isDownloading[downloadKey] = true;
});
try {
// 1. 构建 URL
int songId = widget.song.id;
String url;
// UT 谱面通常没有标准的 CDN adx 下载地址,这里只处理 SD 和 DX
if (type == 'UT') {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("UT 宴会谱暂不支持直接下载 ADX 文件")),
);
setState(() => _isDownloading[downloadKey] = false);
return;
}
if (type == 'SD') {
// SD: cdn.godserver.cn/resource/static/adx/00000/{songId}.adx
String idStr = songId.toString().padLeft(5, '0');
url = "https://cdn.godserver.cn/resource/static/adx/$idStr.adx";
} else {
// DX: cdn.godserver.cn/resource/static/adx/00000/{songId+10000}.adx
int dxId = songId + 10000;
String idStr = dxId.toString().padLeft(5, '0');
url = "https://cdn.godserver.cn/resource/static/adx/$idStr.adx";
}
// 2. 下载文件
final response = await http.get(Uri.parse(url));
if (response.statusCode != 200) {
throw Exception("下载失败: HTTP ${response.statusCode}");
}
// 3. 获取临时目录并保存文件
final directory = await getApplicationDocumentsDirectory();
// 注意iOS 上 getApplicationDocumentsDirectory 是持久化的。
// 如果希望每次下载都清理,可以使用 getTemporaryDirectory()。
// 这里为了稳定性,使用 DocumentsDirectory但文件名保持唯一性。
// 生成文件名: SongTitle_Type_Level.adx
String safeTitle = widget.song.title?.replaceAll(RegExp(r'[^\w\s\u4e00-\u9fa5]'), '_') ?? "song";
String fileName = "${safeTitle}_${type}_Lv${levelId}.adx";
// 确保文件名不冲突,可以加时间戳或者随机数,这里简单处理
final filePath = "${directory.path}/$fileName";
final file = File(filePath);
await file.writeAsBytes(response.bodyBytes);
// 4. 调用系统原生打开
// open_file 会尝试寻找能打开 .adx 的应用
final result = await OpenFile.open(filePath);
if (result.type != ResultType.done) {
// 如果没有安装能打开 adx 的应用,提示用户
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[downloadKey] = false;
});
}
}
Future<void> _loadTypeChartData(String type) async { Future<void> _loadTypeChartData(String type) async {
// 已经加载/正在加载 → 直接返回
if (_chartDataCache.containsKey(type) || _isLoadingChart[type] == true) { if (_chartDataCache.containsKey(type) || _isLoadingChart[type] == true) {
return; return;
} }
@@ -94,7 +183,6 @@ class _SongDetailPageState extends State<SongDetailPage> {
return; return;
} }
// 获取当前类型的 API ID
int apiMusicId; int apiMusicId;
if (type == 'SD') { if (type == 'SD') {
apiMusicId = widget.song.id; apiMusicId = widget.song.id;
@@ -105,11 +193,9 @@ class _SongDetailPageState extends State<SongDetailPage> {
return; return;
} }
// 调用一次 API拿到全部难度数据
final logs = await UserService.getChartLog(token, [apiMusicId]); final logs = await UserService.getChartLog(token, [apiMusicId]);
logs.sort((a, b) => a.time.compareTo(b.time)); logs.sort((a, b) => a.time.compareTo(b.time));
// 转换数据
final chartList = logs.map((log) => { final chartList = logs.map((log) => {
'achievement': log.segaChartNew?.achievement ?? 0, 'achievement': log.segaChartNew?.achievement ?? 0,
'time': log.time, 'time': log.time,
@@ -118,7 +204,7 @@ class _SongDetailPageState extends State<SongDetailPage> {
'syncStatus': log.segaChartNew?.syncStatus ?? 0, 'syncStatus': log.segaChartNew?.syncStatus ?? 0,
'deluxscoreMax': log.segaChartNew?.deluxscoreMax ?? 0, 'deluxscoreMax': log.segaChartNew?.deluxscoreMax ?? 0,
'playCount': log.segaChartNew?.playCount ?? 0, 'playCount': log.segaChartNew?.playCount ?? 0,
'level': log.segaChartNew?.level ?? 0, // 保存难度等级用于过滤 'level': log.segaChartNew?.level ?? 0,
}).toList(); }).toList();
setState(() { setState(() {
@@ -131,7 +217,6 @@ class _SongDetailPageState extends State<SongDetailPage> {
} }
} }
// UT 单独加载
Future<void> _loadUtChartData(String cacheKey, int realId) async { Future<void> _loadUtChartData(String cacheKey, int realId) async {
if (_chartDataCache.containsKey(cacheKey) || _isLoadingChart[cacheKey] == true) { if (_chartDataCache.containsKey(cacheKey) || _isLoadingChart[cacheKey] == true) {
return; return;
@@ -170,6 +255,73 @@ class _SongDetailPageState extends State<SongDetailPage> {
} }
} }
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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final coverUrl = _getCoverUrl(widget.song.id); final coverUrl = _getCoverUrl(widget.song.id);
@@ -177,129 +329,133 @@ class _SongDetailPageState extends State<SongDetailPage> {
final availableTypes = _getAvailableTypes(); final availableTypes = _getAvailableTypes();
return Scaffold( return Scaffold(
appBar: AppBar(title: Text(widget.song.title ?? "歌曲详情")), appBar: AppBar(
title: Text(widget.song.title ?? "歌曲详情"),
elevation: 2,
),
body: SingleChildScrollView( body: SingleChildScrollView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 16),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// 歌曲封面 + 信息 Card(
Row( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
crossAxisAlignment: CrossAxisAlignment.start, elevation: 4,
// 修复:这里缺少了括号 shadowColor: Colors.purpleAccent.withOpacity(0.2),
children: [ child: Padding(
ClipRRect( padding: const EdgeInsets.all(14),
borderRadius: BorderRadius.circular(12), child: Row(
child: Image.network( crossAxisAlignment: CrossAxisAlignment.start,
coverUrl, children: [
width: 120, ClipRRect(
height: 120, borderRadius: BorderRadius.circular(10),
fit: BoxFit.cover, child: Image.network(
errorBuilder: (_, __, ___) => Container( coverUrl,
width: 120, width: 90,
height: 120, height: 90,
color: Colors.grey[200], fit: BoxFit.cover,
child: const Icon(Icons.music_note, size: 40), errorBuilder: (_, __, ___) => Container(
), width: 90,
), height: 90,
), color: Colors.grey[200],
const SizedBox(width: 16), child: const Icon(Icons.music_note, size: 36),
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: 14),
const SizedBox(width: 10), Expanded(
Expanded( child: Column(
child: CupertinoSlidingSegmentedControl<String>( crossAxisAlignment: CrossAxisAlignment.start,
groupValue: _selectedType, children: [
backgroundColor: Colors.grey.shade200, Text(
thumbColor: Theme.of(context).primaryColor.withOpacity(0.8), "[${widget.song.id}] ${widget.song.title}",
children: { style: const TextStyle(
for (var type in availableTypes) fontSize: 17,
type: Padding( fontWeight: FontWeight.bold,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Text(
type,
style: TextStyle(
color: _selectedType == type ? Colors.white : Colors.black87,
fontWeight: FontWeight.bold,
),
), ),
maxLines: 2,
overflow: TextOverflow.ellipsis,
), ),
}, const SizedBox(height: 6),
onValueChanged: (value) { Text(
if (value != null) { "艺术家:${widget.song.artist ?? '未知'}",
setState(() { style: const TextStyle(fontSize: 13),
_selectedType = value; ),
}); 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]),
),
],
),
), ),
), ],
], ),
), ),
] else ...[ ),
const Text(
"难度详情", const SizedBox(height: 20),
style: TextStyle(fontSize: 17, fontWeight: FontWeight.bold),
), if (availableTypes.isNotEmpty) ...[
], Padding(
const SizedBox(height: 12), 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) if (diffs.isEmpty)
Center( const Center(
child: Padding( child: Padding(
padding: const EdgeInsets.all(20.0), padding: EdgeInsets.all(30),
child: Text( child: Text("暂无谱面数据", style: TextStyle(color: Colors.grey)),
"暂无 ${_selectedType ?? ''} 谱面数据",
style: const TextStyle(color: Colors.grey),
),
), ),
) )
else else
...diffs.map((item) => _diffItem( ...diffs.map((item) => _diffItem(
type: item['type'], type: item['type'],
diff: item['diff'], diff: item['diff'],
)).toList(), )),
const SizedBox(height: 30), const SizedBox(height: 30),
], ],
@@ -310,8 +466,7 @@ class _SongDetailPageState extends State<SongDetailPage> {
Widget _diffItem({required String type, required Map diff}) { Widget _diffItem({required String type, required Map diff}) {
int levelId = diff['level_id'] ?? 0; int levelId = diff['level_id'] ?? 0;
final double lvValue = final double lvValue = double.tryParse(diff['level_value']?.toString() ?? '') ?? 0;
double.tryParse(diff['level_value']?.toString() ?? '') ?? 0;
final designer = diff['note_designer'] ?? "-"; final designer = diff['note_designer'] ?? "-";
final notes = diff['notes'] ?? <String, dynamic>{}; final notes = diff['notes'] ?? <String, dynamic>{};
final total = notes['total'] ?? 0; final total = notes['total'] ?? 0;
@@ -323,9 +478,7 @@ class _SongDetailPageState extends State<SongDetailPage> {
int realId; int realId;
String? utTitleName; String? utTitleName;
String cacheKey = ""; String cacheKey = "";
bool hasScoreData = false;
// 加载逻辑SD/DX 只加载一次UT 每个加载一次
if (type == 'UT') { if (type == 'UT') {
realId = (diff['id'] as num?)?.toInt() ?? 0; realId = (diff['id'] as num?)?.toInt() ?? 0;
final utTitleMap = widget.song.utTitle as Map?; final utTitleMap = widget.song.utTitle as Map?;
@@ -339,31 +492,26 @@ class _SongDetailPageState extends State<SongDetailPage> {
}); });
} else { } else {
realId = _getRealMusicId(type); realId = _getRealMusicId(type);
cacheKey = type; // SD/DX 用类型做缓存 key cacheKey = type;
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
_loadTypeChartData(type); _loadTypeChartData(type);
}); });
} }
// 图表数据SD/DX 自动过滤当前难度UT 直接使用
List<Map<String, dynamic>> chartHistory = []; List<Map<String, dynamic>> chartHistory = [];
if (_chartDataCache.containsKey(cacheKey)) { if (_chartDataCache.containsKey(cacheKey)) {
if (type == 'UT') { if (type == 'UT') {
chartHistory = _chartDataCache[cacheKey]!; chartHistory = _chartDataCache[cacheKey]!;
} else { } else {
// SD/DX 从全量数据中过滤当前难度
chartHistory = _chartDataCache[cacheKey]! chartHistory = _chartDataCache[cacheKey]!
.where((e) => e['level'] == levelId) .where((e) => e['level'] == levelId)
.toList(); .toList();
} }
} }
hasScoreData = chartHistory.isNotEmpty; bool hasScoreData = chartHistory.isNotEmpty;
// 成绩信息
final score = widget.userScoreCache[realId]?[levelId]; final score = widget.userScoreCache[realId]?[levelId];
bool hasUserScore = score != null; bool hasUserScore = score != null;
// 显示名称与颜色
String name = ""; String name = "";
Color color = Colors.grey; Color color = Colors.grey;
bool isBanquet = type == 'UT'; bool isBanquet = type == 'UT';
@@ -382,11 +530,23 @@ class _SongDetailPageState extends State<SongDetailPage> {
} }
} }
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);
// 下载状态的 Key
String downloadKey = "${type}_$levelId";
bool isDownloading = _isDownloading[downloadKey] ?? false;
return Card( return Card(
margin: const EdgeInsets.only(bottom: 10), margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
elevation: 5,
shadowColor: color.withOpacity(0.2),
child: Padding( child: Padding(
padding: const EdgeInsets.all(14), padding: const EdgeInsets.all(16),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -394,11 +554,11 @@ class _SongDetailPageState extends State<SongDetailPage> {
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration( decoration: BoxDecoration(
color: color.withOpacity(0.15), color: color.withOpacity(0.15),
borderRadius: BorderRadius.circular(6), borderRadius: BorderRadius.circular(8),
border: Border.all(color: color.withOpacity(0.3)), border: Border.all(color: color.withOpacity(0.4), width: 1.2),
), ),
child: Text( child: Text(
isBanquet ? name : "$name (${lvValue.toStringAsFixed(1)})", isBanquet ? name : "$name (${lvValue.toStringAsFixed(1)})",
@@ -409,20 +569,116 @@ class _SongDetailPageState extends State<SongDetailPage> {
), ),
), ),
), ),
if (hasUserScore) Row(
const Icon(Icons.star, color: Colors.amber, size: 20), children: [
if (hasUserScore)
const Icon(Icons.star_rounded, color: Colors.amber, size: 22),
// ===================== 【新增】下载/预览按钮 =====================
const SizedBox(width: 8),
InkWell(
onTap: isDownloading || type == 'UT' ? null : () {
_downloadAndOpenAdx(type, diff);
},
borderRadius: BorderRadius.circular(20),
child: Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: type == 'UT' ? Colors.grey.shade300 : Theme.of(context).primaryColor.withOpacity(0.1),
shape: BoxShape.circle,
),
child: isDownloading
? SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(color),
),
)
: Icon(
Icons.download_rounded,
size: 18,
color: type == 'UT' ? Colors.grey : color,
),
),
),
],
),
], ],
), ),
const SizedBox(height: 10),
Text("谱师:$designer", style: const TextStyle(fontSize: 14)),
const SizedBox(height: 8), const SizedBox(height: 8),
Text("谱师:$designer", style: const TextStyle(fontSize: 13)),
const SizedBox(height: 4), Table(
Text( columnWidths: const {
"物量:$total | TAP:$tap HOLD:$hold SLIDE:$slide BRK:$brk", 0: FixedColumnWidth(40),
style: const TextStyle(color: Colors.grey, fontSize: 12), 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: 12), const SizedBox(height: 14),
// ✅ 修复:没有成绩直接显示文字,绝不显示加载圈
if (hasScoreData) if (hasScoreData)
Column( Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -430,30 +686,32 @@ class _SongDetailPageState extends State<SongDetailPage> {
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
const Text("推分进程", style: TextStyle(fontSize: 12, color: Colors.grey)), const Text("推分进程", style: TextStyle(fontSize: 13, color: Colors.grey)),
Text( Text(
"最近: ${(chartHistory.last['achievement'] / 10000.0).toStringAsFixed(4)}%", "最近: ${(chartHistory.last['achievement'] / 10000.0).toStringAsFixed(4)}%",
style: TextStyle(fontSize: 12, color: color), style: TextStyle(fontSize: 13, color: color, fontWeight: FontWeight.w600),
), ),
], ],
), ),
const SizedBox(height: 4), const SizedBox(height: 8),
ScoreProgressChart( ScoreProgressChart(
historyScores: chartHistory, historyScores: chartHistory,
lineColor: color, lineColor: color,
fillColor: color, fillColor: color,
), ),
const SizedBox(height: 5),
], ],
) )
else else
const Text("暂无历史记录", style: TextStyle(fontSize: 12, color: Colors.grey)), const Padding(
padding: EdgeInsets.symmetric(vertical: 8),
child: Text("暂无历史记录", style: TextStyle(fontSize: 13, color: Colors.grey)),
),
if (hasUserScore) ...[ if (hasUserScore) ...[
const SizedBox(height: 10), const SizedBox(height: 12),
const Divider(height: 1), Divider(height: 1, color: Colors.grey[300]),
const SizedBox(height: 6), const SizedBox(height: 12),
_buildScoreInfo(score!), _buildScoreInfo(score!,diff),
], ],
], ],
), ),
@@ -461,41 +719,212 @@ class _SongDetailPageState extends State<SongDetailPage> {
); );
} }
Widget _buildScoreInfo(Map score) { 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 ach = (score['achievement'] ?? 0) / 10000;
final rank = _getRankText(score['scoreRank'] ?? 0); final rank = _getRankText(score['scoreRank'] ?? 0);
final combo = _comboText(score['comboStatus'] ?? 0);
final sync = _syncText(score['syncStatus'] ?? 0);
final dxScore = score['deluxscoreMax'] ?? 0; final dxScore = score['deluxscoreMax'] ?? 0;
final playCount = score['playCount'] ?? 0; final playCount = score['playCount'] ?? 0;
final rating = score['rating'] ?? 0;
return Column( 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, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Text("你的成绩:", Expanded(
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), child: Column(
const SizedBox(height: 4), crossAxisAlignment: CrossAxisAlignment.start,
Row( children: [
children: [ const Row(
Text("达成率:${ach.toStringAsFixed(4)}%", children: [
style: TextStyle( Icon(Icons.emoji_events_rounded, color: Colors.amber, size: 18),
color: _getColorByAchievement(ach), SizedBox(width: 4),
fontWeight: FontWeight.bold)), Text(
const Spacer(), "你的成绩",
Text("评级:$rank", style: TextStyle(
style: TextStyle( fontWeight: FontWeight.bold,
color: _getColorByRank(rank), 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])),
],
),
],
),
), ),
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)),
], ],
); );
} }
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) { Color _getColorByAchievement(double ach) {
if (ach >= 100.5) return const Color(0xFFD4AF37); if (ach >= 100.5) return const Color(0xFFD4AF37);
if (ach >= 100.0) return Colors.purple; if (ach >= 100.0) return Colors.purple;
@@ -507,9 +936,10 @@ class _SongDetailPageState extends State<SongDetailPage> {
} }
Color _getColorByRank(String rank) { Color _getColorByRank(String rank) {
if (rank.contains("SSS+")) return const Color(0xFFD4AF37);
if (rank.contains("SSS")) return Colors.purple; if (rank.contains("SSS")) return Colors.purple;
if (rank.contains("SS")) return Colors.deepPurple; if (rank.contains("SS+") || rank.contains("SS")) return Colors.deepPurple;
if (rank.contains("S")) return Colors.blue; if (rank.contains("S+") || rank.contains("S")) return Colors.blue;
return Colors.green; return Colors.green;
} }
@@ -542,26 +972,6 @@ class _SongDetailPageState extends State<SongDetailPage> {
} }
} }
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) { int _getRealMusicId(String type) {
if (type == "SD") return widget.song.id; if (type == "SD") return widget.song.id;
if (type == "DX") return 10000 + widget.song.id; if (type == "DX") return 10000 + widget.song.id;

View File

@@ -32,8 +32,9 @@ class _ScorePageState extends State<ScorePage> with SingleTickerProviderStateMix
bool _isAdvancedFilterExpanded = false; bool _isAdvancedFilterExpanded = false;
double? _minAchievement; double? _minAchievement;
late final TextEditingController _minAchievementController; late final TextEditingController _minAchievementController;
String? _filterFromVersion; // 改为多选
String? _filterGenre; List<String> _filterFromVersions = [];
List<String> _filterGenres = [];
double? _selectedMinLevel; double? _selectedMinLevel;
double? _selectedMaxLevel; double? _selectedMaxLevel;
int? _filterComboStatus; int? _filterComboStatus;
@@ -57,7 +58,7 @@ class _ScorePageState extends State<ScorePage> with SingleTickerProviderStateMix
String get _currentDataSource => UserProvider.instance.scoreDataSource; String get _currentDataSource => UserProvider.instance.scoreDataSource;
String? get _currentSegaId => UserProvider.instance.selectedSegaId; String? get _currentSegaId => UserProvider.instance.selectedSegaId;
String? get _currentCnUserName => UserProvider.instance.selectedCnUserName; // 新增 String? get _currentCnUserName => UserProvider.instance.selectedCnUserName;
@override @override
void initState() { void initState() {
@@ -148,7 +149,6 @@ class _ScorePageState extends State<ScorePage> with SingleTickerProviderStateMix
} }
} }
/// 【核心修改】加载数据逻辑
Future<void> _loadData({bool isInitialLoad = false}) async { Future<void> _loadData({bool isInitialLoad = false}) async {
if (isInitialLoad) { if (isInitialLoad) {
setState(() { _isLoading = true; }); setState(() { _isLoading = true; });
@@ -161,7 +161,6 @@ class _ScorePageState extends State<ScorePage> with SingleTickerProviderStateMix
final token = userProvider.token; final token = userProvider.token;
if (token == null) throw "未登录,请先登录"; if (token == null) throw "未登录,请先登录";
// 1. 获取歌曲元数据
if (_allSongs.isEmpty || isInitialLoad) { if (_allSongs.isEmpty || isInitialLoad) {
final songs = await SongService.getAllSongs(); final songs = await SongService.getAllSongs();
_allSongs = songs; _allSongs = songs;
@@ -170,9 +169,7 @@ class _ScorePageState extends State<ScorePage> with SingleTickerProviderStateMix
_availableGenres = songs.map((s) => s.genre).where((g) => g.isNotEmpty).toSet(); _availableGenres = songs.map((s) => s.genre).where((g) => g.isNotEmpty).toSet();
} }
// 2. 获取用户成绩数据
if (_currentDataSource == 'sega') { if (_currentDataSource == 'sega') {
// --- Sega ID 模式 ---
final segaId = _currentSegaId; final segaId = _currentSegaId;
if (segaId == null || segaId.isEmpty) { if (segaId == null || segaId.isEmpty) {
throw "请选择一个有效的 Sega ID"; throw "请选择一个有效的 Sega ID";
@@ -207,8 +204,6 @@ class _ScorePageState extends State<ScorePage> with SingleTickerProviderStateMix
}).toList(); }).toList();
} else { } else {
// --- 国服模式 ---
// 【修改点】传递选中的用户名
final scoreData = await SongService.getUserAllScores(token,name: _currentCnUserName); final scoreData = await SongService.getUserAllScores(token,name: _currentCnUserName);
if (scoreData.containsKey('userScoreAll_')) { if (scoreData.containsKey('userScoreAll_')) {
@@ -243,13 +238,12 @@ class _ScorePageState extends State<ScorePage> with SingleTickerProviderStateMix
} }
} }
/// 【核心修改】获取过滤并排序后的列表
List<dynamic> get _filteredMusicList { List<dynamic> get _filteredMusicList {
bool isNoFilter = _searchQuery.isEmpty && bool isNoFilter = _searchQuery.isEmpty &&
_filterLevelType == null && _filterLevelType == null &&
_filterRank == null && _filterRank == null &&
_filterFromVersion == null && _filterFromVersions.isEmpty &&
_filterGenre == null && _filterGenres.isEmpty &&
_selectedMinLevel == null && _selectedMinLevel == null &&
_selectedMaxLevel == null && _selectedMaxLevel == null &&
_minAchievement == null && _minAchievement == null &&
@@ -315,10 +309,12 @@ class _ScorePageState extends State<ScorePage> with SingleTickerProviderStateMix
} }
if (song != null) { if (song != null) {
if (_filterFromVersion != null && song.from != _filterFromVersion) return false; // 版本多选过滤
if (_filterGenre != null && song.genre != _filterGenre) return false; if (_filterFromVersions.isNotEmpty && !_filterFromVersions.contains(song.from)) return false;
// 流派多选过滤
if (_filterGenres.isNotEmpty && !_filterGenres.contains(song.genre)) return false;
} else { } else {
if (_filterFromVersion != null || _filterGenre != null) { if (_filterFromVersions.isNotEmpty || _filterGenres.isNotEmpty) {
return false; return false;
} }
} }
@@ -373,8 +369,8 @@ class _ScorePageState extends State<ScorePage> with SingleTickerProviderStateMix
_searchQuery = ''; _searchQuery = '';
_filterLevelType = null; _filterLevelType = null;
_filterRank = null; _filterRank = null;
_filterFromVersion = null; _filterFromVersions.clear();
_filterGenre = null; _filterGenres.clear();
_selectedMinLevel = null; _selectedMinLevel = null;
_selectedMaxLevel = null; _selectedMaxLevel = null;
_minAchievement = null; _minAchievement = null;
@@ -415,16 +411,13 @@ class _ScorePageState extends State<ScorePage> with SingleTickerProviderStateMix
); );
} }
/// 【核心修改】构建数据源选择器,支持国服多账号
Widget _buildDataSourceSelector() { Widget _buildDataSourceSelector() {
final userProvider = UserProvider.instance; final userProvider = UserProvider.instance;
final isSegaMode = userProvider.scoreDataSource == 'sega'; final isSegaMode = userProvider.scoreDataSource == 'sega';
// 国服相关数据
final cnUserNames = userProvider.availableCnUserNames; final cnUserNames = userProvider.availableCnUserNames;
final currentCnUserName = userProvider.selectedCnUserName; final currentCnUserName = userProvider.selectedCnUserName;
// Sega相关数据
final segaCards = userProvider.availableSegaCards; final segaCards = userProvider.availableSegaCards;
final currentSegaId = userProvider.selectedSegaId; final currentSegaId = userProvider.selectedSegaId;
@@ -459,10 +452,10 @@ class _ScorePageState extends State<ScorePage> with SingleTickerProviderStateMix
flex: 1, flex: 1,
child: DropdownButtonHideUnderline( child: DropdownButtonHideUnderline(
child: DropdownButtonFormField<String>( child: DropdownButtonFormField<String>(
// 如果是 Sega 模式,显示 SegaID如果是国服模式显示用户名
value: isSegaMode value: isSegaMode
? currentSegaId ? currentSegaId
: (currentCnUserName != null && currentCnUserName.isNotEmpty ? currentCnUserName : null), decoration: InputDecoration( : (currentCnUserName != null && currentCnUserName.isNotEmpty ? currentCnUserName : null),
decoration: InputDecoration(
hintText: isSegaMode ? "选择卡片" : "选择账号", hintText: isSegaMode ? "选择卡片" : "选择账号",
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 0), contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 0),
@@ -476,7 +469,6 @@ class _ScorePageState extends State<ScorePage> with SingleTickerProviderStateMix
); );
}).toList() }).toList()
: [ : [
// 国服选项:第一个是“全部/默认”,其余是具体用户名
const DropdownMenuItem<String>( const DropdownMenuItem<String>(
value: null, value: null,
child: Text("全部/默认", overflow: TextOverflow.ellipsis), child: Text("全部/默认", overflow: TextOverflow.ellipsis),
@@ -598,27 +590,31 @@ class _ScorePageState extends State<ScorePage> with SingleTickerProviderStateMix
children: [ children: [
Row( Row(
children: [ children: [
// 版本多选按钮
Expanded( Expanded(
child: _buildDropdown( child: _buildMultiSelectButton(
label: "版本", title: "版本",
value: _filterFromVersion, selectedList: _filterFromVersions,
items: [ allItems: _availableVersions.toList(),
const DropdownMenuItem(value: null, child: Text("全部")), onConfirm: (selected) {
..._availableVersions.map((v) => DropdownMenuItem(value: v, child: Text(v))).toList() setState(() {
], _filterFromVersions = selected;
onChanged: (val) => setState(() => _filterFromVersion = val), });
},
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
// 流派多选按钮
Expanded( Expanded(
child: _buildDropdown( child: _buildMultiSelectButton(
label: "流派", title: "流派",
value: _filterGenre, selectedList: _filterGenres,
items: [ allItems: _availableGenres.toList(),
const DropdownMenuItem(value: null, child: Text("全部")), onConfirm: (selected) {
..._availableGenres.map((g) => DropdownMenuItem(value: g, child: Text(g))).toList() setState(() {
], _filterGenres = selected;
onChanged: (val) => setState(() => _filterGenre = val), });
},
), ),
), ),
], ],
@@ -707,6 +703,115 @@ class _ScorePageState extends State<ScorePage> with SingleTickerProviderStateMix
); );
} }
// 多选弹窗组件
Widget _buildMultiSelectButton({
required String title,
required List<String> selectedList,
required List<String> allItems,
required Function(List<String>) onConfirm,
}) {
return InkWell(
onTap: () {
_showMultiSelectDialog(
context: context,
title: title,
selected: List.from(selectedList),
items: allItems,
onConfirm: onConfirm,
);
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade400),
borderRadius: BorderRadius.circular(4),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(title, style: TextStyle(fontWeight: FontWeight.bold)),
Expanded(
child: Text(
selectedList.isEmpty
? "全部"
: selectedList.join(", "),
style: TextStyle(color: Colors.grey[600], fontSize: 13),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.right,
),
),
const Icon(Icons.arrow_drop_down, color: Colors.grey),
],
),
),
);
}
// 显示多选弹窗
Future<void> _showMultiSelectDialog({
required BuildContext context,
required String title,
required List<String> selected,
required List<String> items,
required Function(List<String>) onConfirm,
}) async {
List<String> tempSelected = List.from(selected);
await showDialog(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text("选择$title"),
content: SingleChildScrollView(
child: StatefulBuilder( // <--- 添加 StatefulBuilder
builder: (context, setState) { // <--- 这里的 setState 只用于刷新 Dialog 内容
return Column(
mainAxisSize: MainAxisSize.min,
children: items.map((item) {
return CheckboxListTile(
title: Text(item),
value: tempSelected.contains(item),
onChanged: (isChecked) {
// 使用 StatefulBuilder 提供的 setState
setState(() {
if (isChecked == true) {
tempSelected.add(item);
} else {
tempSelected.remove(item);
}
});
},
);
}).toList(),
);
},
),
),
actions: [
TextButton(
onPressed: () {
Navigator.pop(dialogContext);
},
child: const Text("取消"),
),
TextButton(
onPressed: () {
onConfirm(tempSelected);
Navigator.pop(dialogContext);
},
child: const Text("确定"),
),
TextButton(
onPressed: () {
onConfirm([]);
Navigator.pop(dialogContext);
},
child: const Text("重置", style: TextStyle(color: Colors.red)),
),
],
),
);
}
void _showLevelPickerDialog() { void _showLevelPickerDialog() {
int targetMinIndex = 0; int targetMinIndex = 0;
if (_selectedMinLevel != null) { if (_selectedMinLevel != null) {
@@ -884,8 +989,8 @@ class _ScorePageState extends State<ScorePage> with SingleTickerProviderStateMix
return _searchQuery.isNotEmpty || return _searchQuery.isNotEmpty ||
_filterLevelType != null || _filterLevelType != null ||
_filterRank != null || _filterRank != null ||
_filterFromVersion != null || _filterFromVersions.isNotEmpty ||
_filterGenre != null || _filterGenres.isNotEmpty ||
_selectedMinLevel != null || _selectedMinLevel != null ||
_selectedMaxLevel != null || _selectedMaxLevel != null ||
_minAchievement != null || _minAchievement != null ||
@@ -1145,77 +1250,7 @@ class _ScorePageState extends State<ScorePage> with SingleTickerProviderStateMix
), ),
], ],
), ),
subtitle: Padding( subtitle: _buildScoreInfo(detail,songInfo!),
padding: const EdgeInsets.only(top: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.play_circle_outline, size: 16, color: Colors.grey[600]),
const SizedBox(width: 4),
Text(
"Play: ${detail['playCount'] ?? 0}",
style: TextStyle(fontSize: 12, color: Colors.grey[700]),
),
const SizedBox(width: 12),
Icon(Icons.score, size: 16, color: Colors.grey[600]),
const SizedBox(width: 4),
Text(
"DX: ${detail['deluxscoreMax'] ?? 0}",
style: TextStyle(fontSize: 12, color: Colors.grey[700]),
),
const SizedBox(width: 12),
Icon(Icons.attach_file, size: 16, color: Colors.grey[600]),
const SizedBox(width: 4),
Text(
"Rating: ${detail['rating'] ?? 0}",
style: TextStyle(fontSize: 12, color: Colors.grey[700]),
),
],
),
const SizedBox(height: 4),
Row(
children: [
if (comboStatus > 0)
Container(
margin: const EdgeInsets.only(right: 8),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
border: Border.all(color: comboColor.withAlpha(100)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (comboIcon != null) Icon(comboIcon, size: 12, color: comboColor),
if (comboIcon != null) const SizedBox(width: 2),
Text(comboText, style: TextStyle(fontSize: 14, color: comboColor, fontWeight: FontWeight.bold)),
],
),
),
if (syncStatus > 0)
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
border: Border.all(color: syncColor.withAlpha(100)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (syncIcon != null) Icon(syncIcon, size: 12, color: syncColor),
if (syncIcon != null) const SizedBox(width: 2),
Text(syncText, style: TextStyle(fontSize: 14, color: syncColor, fontWeight: FontWeight.bold)),
],
),
),
],
),
],
),
),
trailing: Column( trailing: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
@@ -1273,7 +1308,122 @@ class _ScorePageState extends State<ScorePage> with SingleTickerProviderStateMix
} }
return Colors.black; return Colors.black;
} }
Widget _buildScoreInfo(Map<String, dynamic> score, SongModel song) {
final dxScore = score['deluxscoreMax'] ?? 0;
final playCount = score['playCount'] ?? 0;
final type = score['type'] ?? '';
final level = score['level'] ?? 0;
final rating = score['rating'] ?? 0;
int comboStatus = score['comboStatus'] ?? 0;
int syncStatus = score['syncStatus'] ?? 0;
int totalNotes = 0;
if ((type == 'dx' && song.dx != null) || (type == 'sd' && song.sd != null)) {
final levelKey = level.toString();
final levelData = type == 'dx' ? song.dx![levelKey] : song.sd![levelKey];
if (levelData != null && levelData['notes'] != null) {
totalNotes = levelData['notes']['total'] ?? 0;
}
}
final allDx = totalNotes * 3;
final 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 Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Flexible(
child: Row(
children: [
const Icon(Icons.score_rounded, size: 14, color: Colors.blueGrey),
const SizedBox(width: 4),
Text(
"DX: $dxScore / $allDx",
style: const TextStyle(fontSize: 13, color: Colors.blueGrey, fontWeight: FontWeight.w500),
),
const SizedBox(width: 10),
const Icon(Icons.play_arrow_rounded, size: 14, color: Colors.grey),
const SizedBox(width: 4),
Text(
"$playCount",
style: const TextStyle(fontSize: 13, color: Colors.grey),
),
const SizedBox(width: 10),
const Icon(Icons.star_rate_rounded, size: 14, color: Colors.amber),
const SizedBox(width: 4),
Text(
"Rating: $rating",
style: const TextStyle(fontSize: 13,fontWeight: FontWeight.w500),
),
const SizedBox(width: 8),
if (comboIconPath != null)
Padding(
padding: const EdgeInsets.only(left: 4),
child: Image.asset(comboIconPath, width: 30, height: 30),
),
if (syncIconPath != null)
Padding(
padding: const EdgeInsets.only(left: 4),
child: Image.asset(syncIconPath, width: 30, height: 30),
),
],
),
),
if (perc >= 0.85) _dxStarIcon(perc),
],
),
);
}
Widget _dxStarIcon(double perc) {
String asset;
if (perc >= 0.97) {
asset = "images/UI_GAM_Gauge_DXScoreIcon_05.png";
} else if (perc >= 0.95) {
asset = "images/UI_GAM_Gauge_DXScoreIcon_04.png";
} else if (perc >= 0.93) {
asset = "images/UI_GAM_Gauge_DXScoreIcon_03.png";
} else if (perc >= 0.90) {
asset = "images/UI_GAM_Gauge_DXScoreIcon_02.png";
} else {
asset = "images/UI_GAM_Gauge_DXScoreIcon_01.png";
}
return Image.asset(
asset,
width: 30,
height: 30,
fit: BoxFit.contain,
);
}
Color _getRankColor(int rank) { Color _getRankColor(int rank) {
return Colors.blueGrey; return Colors.blueGrey;
} }

View File

@@ -34,7 +34,7 @@ class _UserPageState extends State<UserPage> {
Future<void> _loadRadarData() async { Future<void> _loadRadarData() async {
final provider = Provider.of<UserProvider>(context, listen: false); final provider = Provider.of<UserProvider>(context, listen: false);
try { try {
final data = await provider.fetchRadarData("684a6ee7f62aed83538ded34"); final data = await provider.fetchRadarData(provider.user?.id ?? 'default_id');
setState(() { setState(() {
_radarData = data; _radarData = data;
}); });
@@ -648,6 +648,123 @@ class _UserPageState extends State<UserPage> {
); );
} }
// 移除 Sega 账号
Future<void> _removeSegaCard(SegaCard card, UserModel user) async {
final confirm = await showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text("确认移除"),
content: Text("确定要删除 ${card.segaId} 吗?"),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text("取消")),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text("删除"),
),
],
),
);
if (confirm != true) return;
final provider = Provider.of<UserProvider>(context, listen: false);
// 直接删除,不使用 copyWith
List<SegaCard> newList = List.from(user.segaCards ?? [])..remove(card);
// 完全沿用你原来的 UserModel 构造方式
UserModel updatedUser = UserModel(
id: user.id,
name: user.name,
userId: user.userId,
teamId: user.teamId,
email: user.email,
password: user.password,
twoFactorKey: user.twoFactorKey,
apiKey: user.apiKey,
apiBindKey: user.apiBindKey,
protectRole: user.protectRole,
risks: user.risks,
mcName: user.mcName,
userName2userId: user.userName2userId,
lxKey: user.lxKey,
dfUsername: user.dfUsername,
dfPassword: user.dfPassword,
nuoId: user.nuoId,
botId: user.botId,
spasolBotId: user.spasolBotId,
githubId: user.githubId,
rating: user.rating,
ratingMax: user.ratingMax,
iconId: user.iconId,
plateId: user.plateId,
plateIds: user.plateIds,
frameId: user.frameId,
charaSlots: user.charaSlots,
qiandaoDay: user.qiandaoDay,
inviter: user.inviter,
successLogoutTime: user.successLogoutTime,
lastLoginTime: user.lastLoginTime,
friendIds: user.friendIds,
bio: user.bio,
friendBio: user.friendBio,
sex: user.sex,
isDisagreeRecommend: user.isDisagreeRecommend,
isDisagreeFriend: user.isDisagreeFriend,
points: user.points,
planPoints: user.planPoints,
cardIds: user.cardIds,
userCards: user.userCards,
tags: user.tags,
useBeta: user.useBeta,
useNuo: user.useNuo,
useServer: user.useServer,
useB50Type: user.useB50Type,
userHot: user.userHot,
chatInGroupNumbers: user.chatInGroupNumbers,
sc: user.sc,
id2pcNuo: user.id2pcNuo,
mai2links: user.mai2links,
key2KeychipEn: user.key2KeychipEn,
key2key2KeychipEn: user.key2key2KeychipEn,
mai2link: user.mai2link,
userRegion: user.userRegion,
rinUsernameOrEmail: user.rinUsernameOrEmail,
rinPassword: user.rinPassword,
rinChusanUser: user.rinChusanUser,
segaCards: newList, // 这里更新
placeList: user.placeList,
lastKeyChip: user.lastKeyChip,
token: user.token,
timesRegionData: user.timesRegionData,
yearTotal: user.yearTotal,
yearTotalComment: user.yearTotalComment,
userCollCardMap: user.userCollCardMap,
collName2musicIds: user.collName2musicIds,
ai: user.ai,
pkScore: user.pkScore,
pkScoreStr: user.pkScoreStr,
pkScoreReality: user.pkScoreReality,
pkUserId: user.pkUserId,
limitPkTimestamp: user.limitPkTimestamp,
hasAcceptPk: user.hasAcceptPk,
pkPlayNum: user.pkPlayNum,
pkWin: user.pkWin,
userData: user.userData,
banState: user.banState,
);
provider.updateUser(updatedUser);
await provider.saveUserInfo();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("✅ 已移除:${card.segaId}")),
);
}
}
Widget _buildScoreCheckerCard(UserModel user) { Widget _buildScoreCheckerCard(UserModel user) {
return _webCard( return _webCard(
child: Column( child: Column(
@@ -1148,6 +1265,12 @@ class _UserPageState extends State<UserPage> {
const SizedBox(width: 10), const SizedBox(width: 10),
Expanded(child: Text(card.segaId ?? "")), Expanded(child: Text(card.segaId ?? "")),
const SizedBox(width: 6), const SizedBox(width: 6),
TextButton(
style: TextButton.styleFrom(foregroundColor: Colors.red),
onPressed: () => _removeSegaCard(card, user),
child: const Text("移除", style: TextStyle(fontSize: 12)),
),
const SizedBox(width: 4),
TextButton( TextButton(
onPressed: () => _verifyBoundSega(card), onPressed: () => _verifyBoundSega(card),
child: const Text("验证", style: TextStyle(fontSize: 12)), child: const Text("验证", style: TextStyle(fontSize: 12)),

View File

@@ -47,7 +47,6 @@ class RecommendationHelper {
// 提取关键字段 // 提取关键字段
final int musicId = detail['musicId'] ?? detail['id'] ?? 0; final int musicId = detail['musicId'] ?? detail['id'] ?? 0;
if(musicId>16000) continue;
final int level = detail['level'] ?? detail['levelIndex'] ?? 3; // 默认 Master final int level = detail['level'] ?? detail['levelIndex'] ?? 3; // 默认 Master
final int achievement = detail['achievement'] ?? 0; final int achievement = detail['achievement'] ?? 0;
// 确保 rating 是 double // 确保 rating 是 double
@@ -100,6 +99,7 @@ class RecommendationHelper {
for (var song in allSongs) { for (var song in allSongs) {
// 过滤无效 ID // 过滤无效 ID
if (song.id < 100) continue; if (song.id < 100) continue;
if (song.id > 16000) continue;
// 获取 Master (Level 3) 的定数,如果没有则获取 Expert (Level 2) // 获取 Master (Level 3) 的定数,如果没有则获取 Expert (Level 2)
double? masterLevel = _getSongLevel(song, 3); double? masterLevel = _getSongLevel(song, 3);

View File

@@ -6,9 +6,13 @@
#include "generated_plugin_registrant.h" #include "generated_plugin_registrant.h"
#include <open_file_linux/open_file_linux_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h> #include <url_launcher_linux/url_launcher_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) { void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) open_file_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "OpenFileLinuxPlugin");
open_file_linux_plugin_register_with_registrar(open_file_linux_registrar);
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);

View File

@@ -3,6 +3,7 @@
# #
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
open_file_linux
url_launcher_linux url_launcher_linux
) )

View File

@@ -6,6 +6,7 @@ import FlutterMacOS
import Foundation import Foundation
import geolocator_apple import geolocator_apple
import open_file_mac
import package_info_plus import package_info_plus
import share_plus import share_plus
import shared_preferences_foundation import shared_preferences_foundation
@@ -13,6 +14,7 @@ import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin")) GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
OpenFilePlugin.register(with: registry.registrar(forPlugin: "OpenFilePlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))

View File

@@ -536,6 +536,70 @@ packages:
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted source: hosted
version: "9.3.0" version: "9.3.0"
open_file:
dependency: "direct dev"
description:
name: open_file
sha256: b22decdae85b459eac24aeece48f33845c6f16d278a9c63d75c5355345ca236b
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
version: "3.5.11"
open_file_android:
dependency: transitive
description:
name: open_file_android
sha256: "58141fcaece2f453a9684509a7275f231ac0e3d6ceb9a5e6de310a7dff9084aa"
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
version: "1.0.6"
open_file_ios:
dependency: transitive
description:
name: open_file_ios
sha256: a5acd07ba1f304f807a97acbcc489457e1ad0aadff43c467987dd9eef814098f
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
version: "1.0.4"
open_file_linux:
dependency: transitive
description:
name: open_file_linux
sha256: d189f799eecbb139c97f8bc7d303f9e720954fa4e0fa1b0b7294767e5f2d7550
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
version: "0.0.5"
open_file_mac:
dependency: transitive
description:
name: open_file_mac
sha256: cd293f6750de6438ab2390513c99128ade8c974825d4d8128886d1cda8c64d01
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
version: "1.0.4"
open_file_platform_interface:
dependency: transitive
description:
name: open_file_platform_interface
sha256: "101b424ca359632699a7e1213e83d025722ab668b9fd1412338221bf9b0e5757"
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
version: "1.0.3"
open_file_web:
dependency: transitive
description:
name: open_file_web
sha256: e3dbc9584856283dcb30aef5720558b90f88036360bd078e494ab80a80130c4f
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
version: "0.0.4"
open_file_windows:
dependency: transitive
description:
name: open_file_windows
sha256: d26c31ddf935a94a1a3aa43a23f4fff8a5ff4eea395fe7a8cb819cf55431c875
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
version: "0.0.3"
package_config: package_config:
dependency: transitive dependency: transitive
description: description:

View File

@@ -60,12 +60,14 @@ dev_dependencies:
fl_chart: ^0.68.0 fl_chart: ^0.68.0
url_launcher: ^6.2.2 url_launcher: ^6.2.2
share_plus: ^7.2.2 share_plus: ^7.2.2
open_file: ^3.3.2
# For information on the generic Dart part of this file, see the # For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec # following page: https://dart.dev/tools/pub/pubspec
# The following section is specific to Flutter packages. # The following section is specific to Flutter packages.
flutter: flutter:
assets:
- images/
# The following line ensures that the Material Icons font is # The following line ensures that the Material Icons font is
# included with your application, so that you can use the icons in # included with your application, so that you can use the icons in
# the material Icons class. # the material Icons class.