0419 0318
更新4
BIN
images/UI_GAM_Gauge_DXScoreIcon_01.png
Normal file
|
After Width: | Height: | Size: 948 B |
BIN
images/UI_GAM_Gauge_DXScoreIcon_02.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
images/UI_GAM_Gauge_DXScoreIcon_03.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
images/UI_GAM_Gauge_DXScoreIcon_04.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
images/UI_GAM_Gauge_DXScoreIcon_05.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
images/UI_MSS_MBase_Icon_AP.png
Normal file
|
After Width: | Height: | Size: 8.2 KiB |
BIN
images/UI_MSS_MBase_Icon_APp.png
Normal file
|
After Width: | Height: | Size: 8.5 KiB |
BIN
images/UI_MSS_MBase_Icon_FC.png
Normal file
|
After Width: | Height: | Size: 8.8 KiB |
BIN
images/UI_MSS_MBase_Icon_FCp.png
Normal file
|
After Width: | Height: | Size: 9.0 KiB |
BIN
images/UI_MSS_MBase_Icon_FS.png
Normal file
|
After Width: | Height: | Size: 8.6 KiB |
BIN
images/UI_MSS_MBase_Icon_FSD.png
Normal file
|
After Width: | Height: | Size: 9.0 KiB |
BIN
images/UI_MSS_MBase_Icon_FSDp.png
Normal file
|
After Width: | Height: | Size: 9.4 KiB |
BIN
images/UI_MSS_MBase_Icon_FSp.png
Normal file
|
After Width: | Height: | Size: 8.8 KiB |
BIN
images/UI_MSS_MBase_Icon_Sync.png
Normal file
|
After Width: | Height: | Size: 7.8 KiB |
@@ -592,7 +592,7 @@ class _RecommendedSongsSection extends StatelessWidget {
|
||||
}
|
||||
|
||||
return SizedBox(
|
||||
height: 200,
|
||||
height: 220,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: songs.length,
|
||||
@@ -664,7 +664,7 @@ class _SongItemCard extends StatelessWidget {
|
||||
ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(12)),
|
||||
child: SizedBox(
|
||||
height: 120,
|
||||
height: 140,
|
||||
width: double.infinity,
|
||||
child: Image.network(
|
||||
_getCoverUrl(song.id),
|
||||
|
||||
@@ -24,8 +24,11 @@ class _MusicPageState extends State<MusicPage> with SingleTickerProviderStateMix
|
||||
// 筛选
|
||||
String _searchQuery = '';
|
||||
int? _filterLevelType;
|
||||
String? _filterFromVersion;
|
||||
String? _filterGenre;
|
||||
|
||||
// --- 修改点 1: 将单选 String? 改为多选 Set<String> ---
|
||||
Set<String> _filterVersions = {};
|
||||
Set<String> _filterGenres = {};
|
||||
|
||||
double? _selectedMinLevel;
|
||||
double? _selectedMaxLevel;
|
||||
bool _isAdvancedFilterExpanded = false;
|
||||
@@ -99,6 +102,8 @@ class _MusicPageState extends State<MusicPage> with SingleTickerProviderStateMix
|
||||
_allSongs = uniqueSongs.values.toList();
|
||||
_allSongs.sort((a, b) => (b.players ?? 0).compareTo(a.players ?? 0));
|
||||
_displaySongs = List.from(_allSongs);
|
||||
|
||||
// 更新可用选项
|
||||
_availableVersions = songs.map((s) => s.from).where((v) => v.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 {
|
||||
try {
|
||||
final userProvider = UserProvider.instance;
|
||||
@@ -171,8 +176,12 @@ class _MusicPageState extends State<MusicPage> with SingleTickerProviderStateMix
|
||||
if (!match) return false;
|
||||
}
|
||||
|
||||
if (_filterFromVersion != null && song.from != _filterFromVersion) return false;
|
||||
if (_filterGenre != null && song.genre != _filterGenre) return false;
|
||||
// --- 修改点 2: 多选过滤逻辑 ---
|
||||
// 如果选择了版本,歌曲版本必须在选中集合中
|
||||
if (_filterVersions.isNotEmpty && !_filterVersions.contains(song.from)) return false;
|
||||
|
||||
// 如果选择了流派,歌曲流派必须在选中集合中
|
||||
if (_filterGenres.isNotEmpty && !_filterGenres.contains(song.genre)) return false;
|
||||
|
||||
if (_filterLevelType != null) {
|
||||
bool hasLevel = false;
|
||||
@@ -248,8 +257,8 @@ class _MusicPageState extends State<MusicPage> with SingleTickerProviderStateMix
|
||||
setState(() {
|
||||
_searchQuery = '';
|
||||
_filterLevelType = null;
|
||||
_filterFromVersion = null;
|
||||
_filterGenre = null;
|
||||
_filterVersions.clear(); // 清空集合
|
||||
_filterGenres.clear(); // 清空集合
|
||||
_selectedMinLevel = 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
|
||||
Widget build(BuildContext context) {
|
||||
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))),
|
||||
child: Column(
|
||||
children: [
|
||||
// --- 修改点 3: UI 替换为多选 Chip ---
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildDropdown(
|
||||
child: _buildMultiSelectChip(
|
||||
label: "版本",
|
||||
value: _filterFromVersion,
|
||||
items: [
|
||||
const DropdownMenuItem(value: null, child: Text("全部")),
|
||||
..._availableVersions.map((v) => DropdownMenuItem(value: v, child: Text(v))).toList()
|
||||
],
|
||||
onChanged: (v) {
|
||||
setState(() => _filterFromVersion = v);
|
||||
_applyFilters();
|
||||
selectedValues: _filterVersions,
|
||||
allOptions: _availableVersions,
|
||||
onSelectionChanged: (newSet) {
|
||||
_filterVersions = newSet;
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _buildDropdown(
|
||||
child: _buildMultiSelectChip(
|
||||
label: "流派",
|
||||
value: _filterGenre,
|
||||
items: [
|
||||
const DropdownMenuItem(value: null, child: Text("全部")),
|
||||
..._availableGenres.map((g) => DropdownMenuItem(value: g, child: Text(g))).toList()
|
||||
],
|
||||
onChanged: (v) {
|
||||
setState(() => _filterGenre = v);
|
||||
_applyFilters();
|
||||
selectedValues: _filterGenres,
|
||||
allOptions: _availableGenres,
|
||||
onSelectionChanged: (newSet) {
|
||||
_filterGenres = newSet;
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -517,10 +636,12 @@ class _MusicPageState extends State<MusicPage> with SingleTickerProviderStateMix
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String getLeftEllipsisText(String text, int maxLength) {
|
||||
if (text.length <= maxLength) return text;
|
||||
return '...${text.substring(text.length - maxLength + 3)}'; // +3 是因为 "..." 占3个字符
|
||||
}
|
||||
|
||||
Widget _buildDropdown({required String label, required dynamic value, required List<DropdownMenuItem> items, required ValueChanged onChanged}) {
|
||||
return DropdownButtonFormField(
|
||||
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() {
|
||||
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;
|
||||
|
||||
// --- 新增:处理别名逻辑 ---
|
||||
// 1. 获取原始列表
|
||||
List<String> rawAliases = song.albums ?? [];
|
||||
|
||||
// 2. 去重 (保留插入顺序可以使用 LinkedHashSet,或者简单转为 Set 再转 List)
|
||||
// 注意:Set.from 可能会打乱顺序,如果顺序不重要可以直接用。
|
||||
// 如果希望保持原顺序去重:
|
||||
final seen = <String>{};
|
||||
final uniqueAliases = rawAliases.where((alias) => seen.add(alias)).toList();
|
||||
|
||||
// 3. 过滤掉与标题或艺术家完全相同的重复项(可选,为了更整洁)
|
||||
final filteredAliases = uniqueAliases.where((alias) =>
|
||||
alias != song.title &&
|
||||
alias != song.artist
|
||||
).toList();
|
||||
|
||||
// 4. 拼接字符串,如果太长则截断
|
||||
// 我们设定一个最大显示长度或者最大行数,这里采用“尝试放入尽可能多的词,直到超过一定长度”的策略
|
||||
String aliasDisplayText = "";
|
||||
if (filteredAliases.isNotEmpty) {
|
||||
// 尝试加入前 N 个别名,总长度控制在合理范围,例如 60-80 个字符,或者固定个数如 5-8 个
|
||||
// 这里采用固定个数策略,因为每个词长度不一,固定个数更容易预测高度
|
||||
int maxAliasCount = 6;
|
||||
List<String> displayList = filteredAliases.take(maxAliasCount).toList();
|
||||
|
||||
aliasDisplayText = displayList.join(" · ");
|
||||
|
||||
// 如果还有更多未显示的,加上省略提示
|
||||
if (filteredAliases.length > maxAliasCount) {
|
||||
aliasDisplayText += " ...";
|
||||
}
|
||||
@@ -632,12 +745,10 @@ class _MusicPageState extends State<MusicPage> with SingleTickerProviderStateMix
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
// 这一行:id + 左侧标签
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
// 标签区域(cn / jp / m2l / long)
|
||||
if (song.cn == true)
|
||||
_buildTag(
|
||||
"CN",
|
||||
@@ -669,8 +780,6 @@ class _MusicPageState extends State<MusicPage> with SingleTickerProviderStateMix
|
||||
),
|
||||
|
||||
),
|
||||
|
||||
// id 文本
|
||||
Text(
|
||||
" ${song.id}",
|
||||
style: const TextStyle(
|
||||
@@ -703,7 +812,6 @@ class _MusicPageState extends State<MusicPage> with SingleTickerProviderStateMix
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
// 关键:让内容在垂直方向上尽量分布,或者使用 MainAxisSize.min 配合 Spacer
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
@@ -725,13 +833,12 @@ class _MusicPageState extends State<MusicPage> with SingleTickerProviderStateMix
|
||||
const SizedBox(height: 6),
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// 可选:根据剩余宽度动态决定显示多少行,这里简单处理为最多2行
|
||||
return Text(
|
||||
aliasDisplayText,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: Colors.grey[500],
|
||||
height: 1.2, // 紧凑行高
|
||||
height: 1.2,
|
||||
),
|
||||
maxLines: 5,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
@@ -757,16 +864,11 @@ class _MusicPageState extends State<MusicPage> with SingleTickerProviderStateMix
|
||||
}
|
||||
Widget _buildTag(
|
||||
String text, {
|
||||
// 背景:纯色 或 渐变 二选一
|
||||
Color? backgroundColor,
|
||||
Gradient? gradient,
|
||||
|
||||
// 阴影配置
|
||||
Color? shadowColor,
|
||||
double shadowBlurRadius = 2,
|
||||
Offset shadowOffset = const Offset(0, 1),
|
||||
|
||||
// 可选样式扩展
|
||||
double borderRadius = 2,
|
||||
double fontSize = 8,
|
||||
Color textColor = Colors.white,
|
||||
@@ -775,11 +877,9 @@ class _MusicPageState extends State<MusicPage> with SingleTickerProviderStateMix
|
||||
margin: const EdgeInsets.only(right: 3),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
// 优先使用渐变,没有渐变则使用纯色
|
||||
color: gradient == null ? (backgroundColor ?? Colors.blueAccent) : null,
|
||||
gradient: gradient,
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
// 阴影:只有传入 shadowColor 才显示
|
||||
boxShadow: shadowColor != null
|
||||
? [
|
||||
BoxShadow(
|
||||
@@ -802,8 +902,6 @@ class _MusicPageState extends State<MusicPage> with SingleTickerProviderStateMix
|
||||
}
|
||||
List<Widget> _getDifficultyChipsByType(SongModel song) {
|
||||
final diffs = _getAllDifficultiesWithType(song);
|
||||
|
||||
// 按 type 分组:SD / DX / UT
|
||||
final Map<String, List<dynamic>> typeGroups = {};
|
||||
for (var item in diffs) {
|
||||
final type = item['type'] as String;
|
||||
@@ -811,7 +909,6 @@ class _MusicPageState extends State<MusicPage> with SingleTickerProviderStateMix
|
||||
typeGroups[type]!.add(item);
|
||||
}
|
||||
|
||||
// 固定顺序:SD → DX → UT
|
||||
final order = ['SD', 'DX', 'UT'];
|
||||
List<Widget> rows = [];
|
||||
|
||||
@@ -819,7 +916,6 @@ class _MusicPageState extends State<MusicPage> with SingleTickerProviderStateMix
|
||||
final items = typeGroups[type];
|
||||
if (items == null || items.isEmpty) continue;
|
||||
|
||||
// 每一个类型 = 一行 Wrap
|
||||
final row = Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 4,
|
||||
@@ -837,8 +933,6 @@ class _MusicPageState extends State<MusicPage> with SingleTickerProviderStateMix
|
||||
|
||||
if (isBanquet) {
|
||||
color = Colors.pinkAccent;
|
||||
|
||||
// 多UT按ID显示对应名称
|
||||
if (type == 'UT') {
|
||||
final utTitleMap = song.utTitle as Map?;
|
||||
if (utTitleMap != null && utTitleMap.isNotEmpty) {
|
||||
@@ -882,7 +976,7 @@ class _MusicPageState extends State<MusicPage> with SingleTickerProviderStateMix
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(hasScore ? 0.25 : 0.1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
@@ -892,7 +986,7 @@ class _MusicPageState extends State<MusicPage> with SingleTickerProviderStateMix
|
||||
isBanquet ? label : "$label ${lvValue.toStringAsFixed(1)}",
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 10,
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
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 '../../../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/gradientText.dart';
|
||||
|
||||
class SongDetailPage extends StatefulWidget {
|
||||
final SongModel song;
|
||||
@@ -22,8 +27,11 @@ class SongDetailPage extends StatefulWidget {
|
||||
|
||||
class _SongDetailPageState extends State<SongDetailPage> {
|
||||
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, bool> _isLoadingChart = {};
|
||||
|
||||
@@ -74,9 +82,90 @@ class _SongDetailPageState extends State<SongDetailPage> {
|
||||
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 {
|
||||
// 已经加载/正在加载 → 直接返回
|
||||
if (_chartDataCache.containsKey(type) || _isLoadingChart[type] == true) {
|
||||
return;
|
||||
}
|
||||
@@ -94,7 +183,6 @@ class _SongDetailPageState extends State<SongDetailPage> {
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取当前类型的 API ID
|
||||
int apiMusicId;
|
||||
if (type == 'SD') {
|
||||
apiMusicId = widget.song.id;
|
||||
@@ -105,11 +193,9 @@ class _SongDetailPageState extends State<SongDetailPage> {
|
||||
return;
|
||||
}
|
||||
|
||||
// 调用一次 API,拿到全部难度数据
|
||||
final logs = await UserService.getChartLog(token, [apiMusicId]);
|
||||
logs.sort((a, b) => a.time.compareTo(b.time));
|
||||
|
||||
// 转换数据
|
||||
final chartList = logs.map((log) => {
|
||||
'achievement': log.segaChartNew?.achievement ?? 0,
|
||||
'time': log.time,
|
||||
@@ -118,7 +204,7 @@ class _SongDetailPageState extends State<SongDetailPage> {
|
||||
'syncStatus': log.segaChartNew?.syncStatus ?? 0,
|
||||
'deluxscoreMax': log.segaChartNew?.deluxscoreMax ?? 0,
|
||||
'playCount': log.segaChartNew?.playCount ?? 0,
|
||||
'level': log.segaChartNew?.level ?? 0, // 保存难度等级用于过滤
|
||||
'level': log.segaChartNew?.level ?? 0,
|
||||
}).toList();
|
||||
|
||||
setState(() {
|
||||
@@ -131,7 +217,6 @@ class _SongDetailPageState extends State<SongDetailPage> {
|
||||
}
|
||||
}
|
||||
|
||||
// UT 单独加载
|
||||
Future<void> _loadUtChartData(String cacheKey, int realId) async {
|
||||
if (_chartDataCache.containsKey(cacheKey) || _isLoadingChart[cacheKey] == true) {
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
final coverUrl = _getCoverUrl(widget.song.id);
|
||||
@@ -177,129 +329,133 @@ class _SongDetailPageState extends State<SongDetailPage> {
|
||||
final availableTypes = _getAvailableTypes();
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(widget.song.title ?? "歌曲详情")),
|
||||
appBar: AppBar(
|
||||
title: Text(widget.song.title ?? "歌曲详情"),
|
||||
elevation: 2,
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 歌曲封面 + 信息
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
// 修复:这里缺少了括号
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Image.network(
|
||||
coverUrl,
|
||||
width: 120,
|
||||
height: 120,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => Container(
|
||||
width: 120,
|
||||
height: 120,
|
||||
color: Colors.grey[200],
|
||||
child: const Icon(Icons.music_note, size: 40),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
"[${widget.song.id}] ${widget.song.title}",
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
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: Image.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(height: 4),
|
||||
Text(
|
||||
"艺术家:${widget.song.artist ?? '未知'}",
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
Text(
|
||||
"流派:${widget.song.genre} | 版本:${widget.song.from}",
|
||||
style: const TextStyle(fontSize: 13, color: Colors.grey),
|
||||
),
|
||||
Text(
|
||||
"BPM:${widget.song.bpm ?? '未知'}",
|
||||
style: const TextStyle(fontSize: 13, color: Colors.grey),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 难度选择器
|
||||
if (availableTypes.length > 1) ...[
|
||||
Row(
|
||||
children: [
|
||||
const Text(
|
||||
"难度详情",
|
||||
style: TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: CupertinoSlidingSegmentedControl<String>(
|
||||
groupValue: _selectedType,
|
||||
backgroundColor: Colors.grey.shade200,
|
||||
thumbColor: Theme.of(context).primaryColor.withOpacity(0.8),
|
||||
children: {
|
||||
for (var type in availableTypes)
|
||||
type: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Text(
|
||||
type,
|
||||
style: TextStyle(
|
||||
color: _selectedType == type ? Colors.white : Colors.black87,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
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,
|
||||
),
|
||||
},
|
||||
onValueChanged: (value) {
|
||||
if (value != null) {
|
||||
setState(() {
|
||||
_selectedType = value;
|
||||
});
|
||||
}
|
||||
},
|
||||
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]),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
const Text(
|
||||
"难度详情",
|
||||
style: TextStyle(fontSize: 17, fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
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)
|
||||
Center(
|
||||
const Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20.0),
|
||||
child: Text(
|
||||
"暂无 ${_selectedType ?? ''} 谱面数据",
|
||||
style: const TextStyle(color: Colors.grey),
|
||||
),
|
||||
padding: EdgeInsets.all(30),
|
||||
child: Text("暂无谱面数据", style: TextStyle(color: Colors.grey)),
|
||||
),
|
||||
)
|
||||
else
|
||||
...diffs.map((item) => _diffItem(
|
||||
type: item['type'],
|
||||
diff: item['diff'],
|
||||
)).toList(),
|
||||
)),
|
||||
|
||||
const SizedBox(height: 30),
|
||||
],
|
||||
@@ -310,8 +466,7 @@ class _SongDetailPageState extends State<SongDetailPage> {
|
||||
|
||||
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 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;
|
||||
@@ -323,9 +478,7 @@ class _SongDetailPageState extends State<SongDetailPage> {
|
||||
int realId;
|
||||
String? utTitleName;
|
||||
String cacheKey = "";
|
||||
bool hasScoreData = false;
|
||||
|
||||
// 加载逻辑:SD/DX 只加载一次,UT 每个加载一次
|
||||
if (type == 'UT') {
|
||||
realId = (diff['id'] as num?)?.toInt() ?? 0;
|
||||
final utTitleMap = widget.song.utTitle as Map?;
|
||||
@@ -339,31 +492,26 @@ class _SongDetailPageState extends State<SongDetailPage> {
|
||||
});
|
||||
} else {
|
||||
realId = _getRealMusicId(type);
|
||||
cacheKey = type; // SD/DX 用类型做缓存 key
|
||||
cacheKey = type;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_loadTypeChartData(type);
|
||||
});
|
||||
}
|
||||
|
||||
// 图表数据:SD/DX 自动过滤当前难度,UT 直接使用
|
||||
List<Map<String, dynamic>> chartHistory = [];
|
||||
if (_chartDataCache.containsKey(cacheKey)) {
|
||||
if (type == 'UT') {
|
||||
chartHistory = _chartDataCache[cacheKey]!;
|
||||
} else {
|
||||
// SD/DX 从全量数据中过滤当前难度
|
||||
chartHistory = _chartDataCache[cacheKey]!
|
||||
.where((e) => e['level'] == levelId)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
hasScoreData = chartHistory.isNotEmpty;
|
||||
|
||||
// 成绩信息
|
||||
bool hasScoreData = chartHistory.isNotEmpty;
|
||||
final score = widget.userScoreCache[realId]?[levelId];
|
||||
bool hasUserScore = score != null;
|
||||
|
||||
// 显示名称与颜色
|
||||
String name = "";
|
||||
Color color = Colors.grey;
|
||||
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(
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
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(14),
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -394,11 +554,11 @@ class _SongDetailPageState extends State<SongDetailPage> {
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.15),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: color.withOpacity(0.3)),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: color.withOpacity(0.4), width: 1.2),
|
||||
),
|
||||
child: Text(
|
||||
isBanquet ? name : "$name (${lvValue.toStringAsFixed(1)})",
|
||||
@@ -409,20 +569,116 @@ class _SongDetailPageState extends State<SongDetailPage> {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (hasUserScore)
|
||||
const Icon(Icons.star, color: Colors.amber, size: 20),
|
||||
Row(
|
||||
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),
|
||||
Text("谱师:$designer", style: const TextStyle(fontSize: 13)),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
"物量:$total | TAP:$tap HOLD:$hold SLIDE:$slide BRK:$brk",
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 12),
|
||||
|
||||
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: 12),
|
||||
// ✅ 修复:没有成绩直接显示文字,绝不显示加载圈
|
||||
const SizedBox(height: 14),
|
||||
|
||||
if (hasScoreData)
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -430,30 +686,32 @@ class _SongDetailPageState extends State<SongDetailPage> {
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text("推分进程", style: TextStyle(fontSize: 12, color: Colors.grey)),
|
||||
const Text("推分进程", style: TextStyle(fontSize: 13, color: Colors.grey)),
|
||||
Text(
|
||||
"最近: ${(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(
|
||||
historyScores: chartHistory,
|
||||
lineColor: color,
|
||||
fillColor: color,
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
],
|
||||
)
|
||||
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) ...[
|
||||
const SizedBox(height: 10),
|
||||
const Divider(height: 1),
|
||||
const SizedBox(height: 6),
|
||||
_buildScoreInfo(score!),
|
||||
const SizedBox(height: 12),
|
||||
Divider(height: 1, color: Colors.grey[300]),
|
||||
const SizedBox(height: 12),
|
||||
_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 rank = _getRankText(score['scoreRank'] ?? 0);
|
||||
final combo = _comboText(score['comboStatus'] ?? 0);
|
||||
final sync = _syncText(score['syncStatus'] ?? 0);
|
||||
final dxScore = score['deluxscoreMax'] ?? 0;
|
||||
final playCount = score['playCount'] ?? 0;
|
||||
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,
|
||||
children: [
|
||||
const Text("你的成绩:",
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13)),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Text("达成率:${ach.toStringAsFixed(4)}%",
|
||||
style: TextStyle(
|
||||
color: _getColorByAchievement(ach),
|
||||
fontWeight: FontWeight.bold)),
|
||||
const Spacer(),
|
||||
Text("评级:$rank",
|
||||
style: TextStyle(
|
||||
color: _getColorByRank(rank), fontWeight: FontWeight.bold)),
|
||||
],
|
||||
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])),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
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) {
|
||||
if (ach >= 100.5) return const Color(0xFFD4AF37);
|
||||
if (ach >= 100.0) return Colors.purple;
|
||||
@@ -507,9 +936,10 @@ class _SongDetailPageState extends State<SongDetailPage> {
|
||||
}
|
||||
|
||||
Color _getColorByRank(String rank) {
|
||||
if (rank.contains("SSS+")) return const Color(0xFFD4AF37);
|
||||
if (rank.contains("SSS")) return Colors.purple;
|
||||
if (rank.contains("SS")) return Colors.deepPurple;
|
||||
if (rank.contains("S")) return Colors.blue;
|
||||
if (rank.contains("SS+") || rank.contains("SS")) return Colors.deepPurple;
|
||||
if (rank.contains("S+") || rank.contains("S")) return Colors.blue;
|
||||
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) {
|
||||
if (type == "SD") return widget.song.id;
|
||||
if (type == "DX") return 10000 + widget.song.id;
|
||||
|
||||
@@ -32,8 +32,9 @@ class _ScorePageState extends State<ScorePage> with SingleTickerProviderStateMix
|
||||
bool _isAdvancedFilterExpanded = false;
|
||||
double? _minAchievement;
|
||||
late final TextEditingController _minAchievementController;
|
||||
String? _filterFromVersion;
|
||||
String? _filterGenre;
|
||||
// 改为多选
|
||||
List<String> _filterFromVersions = [];
|
||||
List<String> _filterGenres = [];
|
||||
double? _selectedMinLevel;
|
||||
double? _selectedMaxLevel;
|
||||
int? _filterComboStatus;
|
||||
@@ -57,7 +58,7 @@ class _ScorePageState extends State<ScorePage> with SingleTickerProviderStateMix
|
||||
|
||||
String get _currentDataSource => UserProvider.instance.scoreDataSource;
|
||||
String? get _currentSegaId => UserProvider.instance.selectedSegaId;
|
||||
String? get _currentCnUserName => UserProvider.instance.selectedCnUserName; // 新增
|
||||
String? get _currentCnUserName => UserProvider.instance.selectedCnUserName;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -148,7 +149,6 @@ class _ScorePageState extends State<ScorePage> with SingleTickerProviderStateMix
|
||||
}
|
||||
}
|
||||
|
||||
/// 【核心修改】加载数据逻辑
|
||||
Future<void> _loadData({bool isInitialLoad = false}) async {
|
||||
if (isInitialLoad) {
|
||||
setState(() { _isLoading = true; });
|
||||
@@ -161,7 +161,6 @@ class _ScorePageState extends State<ScorePage> with SingleTickerProviderStateMix
|
||||
final token = userProvider.token;
|
||||
if (token == null) throw "未登录,请先登录";
|
||||
|
||||
// 1. 获取歌曲元数据
|
||||
if (_allSongs.isEmpty || isInitialLoad) {
|
||||
final songs = await SongService.getAllSongs();
|
||||
_allSongs = songs;
|
||||
@@ -170,9 +169,7 @@ class _ScorePageState extends State<ScorePage> with SingleTickerProviderStateMix
|
||||
_availableGenres = songs.map((s) => s.genre).where((g) => g.isNotEmpty).toSet();
|
||||
}
|
||||
|
||||
// 2. 获取用户成绩数据
|
||||
if (_currentDataSource == 'sega') {
|
||||
// --- Sega ID 模式 ---
|
||||
final segaId = _currentSegaId;
|
||||
if (segaId == null || segaId.isEmpty) {
|
||||
throw "请选择一个有效的 Sega ID";
|
||||
@@ -207,8 +204,6 @@ class _ScorePageState extends State<ScorePage> with SingleTickerProviderStateMix
|
||||
}).toList();
|
||||
|
||||
} else {
|
||||
// --- 国服模式 ---
|
||||
// 【修改点】传递选中的用户名
|
||||
final scoreData = await SongService.getUserAllScores(token,name: _currentCnUserName);
|
||||
|
||||
if (scoreData.containsKey('userScoreAll_')) {
|
||||
@@ -243,13 +238,12 @@ class _ScorePageState extends State<ScorePage> with SingleTickerProviderStateMix
|
||||
}
|
||||
}
|
||||
|
||||
/// 【核心修改】获取过滤并排序后的列表
|
||||
List<dynamic> get _filteredMusicList {
|
||||
bool isNoFilter = _searchQuery.isEmpty &&
|
||||
_filterLevelType == null &&
|
||||
_filterRank == null &&
|
||||
_filterFromVersion == null &&
|
||||
_filterGenre == null &&
|
||||
_filterFromVersions.isEmpty &&
|
||||
_filterGenres.isEmpty &&
|
||||
_selectedMinLevel == null &&
|
||||
_selectedMaxLevel == null &&
|
||||
_minAchievement == null &&
|
||||
@@ -315,10 +309,12 @@ class _ScorePageState extends State<ScorePage> with SingleTickerProviderStateMix
|
||||
}
|
||||
|
||||
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 {
|
||||
if (_filterFromVersion != null || _filterGenre != null) {
|
||||
if (_filterFromVersions.isNotEmpty || _filterGenres.isNotEmpty) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -373,8 +369,8 @@ class _ScorePageState extends State<ScorePage> with SingleTickerProviderStateMix
|
||||
_searchQuery = '';
|
||||
_filterLevelType = null;
|
||||
_filterRank = null;
|
||||
_filterFromVersion = null;
|
||||
_filterGenre = null;
|
||||
_filterFromVersions.clear();
|
||||
_filterGenres.clear();
|
||||
_selectedMinLevel = null;
|
||||
_selectedMaxLevel = null;
|
||||
_minAchievement = null;
|
||||
@@ -415,16 +411,13 @@ class _ScorePageState extends State<ScorePage> with SingleTickerProviderStateMix
|
||||
);
|
||||
}
|
||||
|
||||
/// 【核心修改】构建数据源选择器,支持国服多账号
|
||||
Widget _buildDataSourceSelector() {
|
||||
final userProvider = UserProvider.instance;
|
||||
final isSegaMode = userProvider.scoreDataSource == 'sega';
|
||||
|
||||
// 国服相关数据
|
||||
final cnUserNames = userProvider.availableCnUserNames;
|
||||
final currentCnUserName = userProvider.selectedCnUserName;
|
||||
|
||||
// Sega相关数据
|
||||
final segaCards = userProvider.availableSegaCards;
|
||||
final currentSegaId = userProvider.selectedSegaId;
|
||||
|
||||
@@ -459,10 +452,10 @@ class _ScorePageState extends State<ScorePage> with SingleTickerProviderStateMix
|
||||
flex: 1,
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButtonFormField<String>(
|
||||
// 如果是 Sega 模式,显示 SegaID;如果是国服模式,显示用户名
|
||||
value: isSegaMode
|
||||
? currentSegaId
|
||||
: (currentCnUserName != null && currentCnUserName.isNotEmpty ? currentCnUserName : null), decoration: InputDecoration(
|
||||
: (currentCnUserName != null && currentCnUserName.isNotEmpty ? currentCnUserName : null),
|
||||
decoration: InputDecoration(
|
||||
hintText: isSegaMode ? "选择卡片" : "选择账号",
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 0),
|
||||
@@ -476,7 +469,6 @@ class _ScorePageState extends State<ScorePage> with SingleTickerProviderStateMix
|
||||
);
|
||||
}).toList()
|
||||
: [
|
||||
// 国服选项:第一个是“全部/默认”,其余是具体用户名
|
||||
const DropdownMenuItem<String>(
|
||||
value: null,
|
||||
child: Text("全部/默认", overflow: TextOverflow.ellipsis),
|
||||
@@ -598,27 +590,31 @@ class _ScorePageState extends State<ScorePage> with SingleTickerProviderStateMix
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
// 版本多选按钮
|
||||
Expanded(
|
||||
child: _buildDropdown(
|
||||
label: "版本",
|
||||
value: _filterFromVersion,
|
||||
items: [
|
||||
const DropdownMenuItem(value: null, child: Text("全部")),
|
||||
..._availableVersions.map((v) => DropdownMenuItem(value: v, child: Text(v))).toList()
|
||||
],
|
||||
onChanged: (val) => setState(() => _filterFromVersion = val),
|
||||
child: _buildMultiSelectButton(
|
||||
title: "版本",
|
||||
selectedList: _filterFromVersions,
|
||||
allItems: _availableVersions.toList(),
|
||||
onConfirm: (selected) {
|
||||
setState(() {
|
||||
_filterFromVersions = selected;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// 流派多选按钮
|
||||
Expanded(
|
||||
child: _buildDropdown(
|
||||
label: "流派",
|
||||
value: _filterGenre,
|
||||
items: [
|
||||
const DropdownMenuItem(value: null, child: Text("全部")),
|
||||
..._availableGenres.map((g) => DropdownMenuItem(value: g, child: Text(g))).toList()
|
||||
],
|
||||
onChanged: (val) => setState(() => _filterGenre = val),
|
||||
child: _buildMultiSelectButton(
|
||||
title: "流派",
|
||||
selectedList: _filterGenres,
|
||||
allItems: _availableGenres.toList(),
|
||||
onConfirm: (selected) {
|
||||
setState(() {
|
||||
_filterGenres = selected;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -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() {
|
||||
int targetMinIndex = 0;
|
||||
if (_selectedMinLevel != null) {
|
||||
@@ -884,8 +989,8 @@ class _ScorePageState extends State<ScorePage> with SingleTickerProviderStateMix
|
||||
return _searchQuery.isNotEmpty ||
|
||||
_filterLevelType != null ||
|
||||
_filterRank != null ||
|
||||
_filterFromVersion != null ||
|
||||
_filterGenre != null ||
|
||||
_filterFromVersions.isNotEmpty ||
|
||||
_filterGenres.isNotEmpty ||
|
||||
_selectedMinLevel != null ||
|
||||
_selectedMaxLevel != null ||
|
||||
_minAchievement != null ||
|
||||
@@ -1145,77 +1250,7 @@ class _ScorePageState extends State<ScorePage> with SingleTickerProviderStateMix
|
||||
),
|
||||
],
|
||||
),
|
||||
subtitle: Padding(
|
||||
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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
subtitle: _buildScoreInfo(detail,songInfo!),
|
||||
trailing: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
@@ -1273,7 +1308,122 @@ class _ScorePageState extends State<ScorePage> with SingleTickerProviderStateMix
|
||||
}
|
||||
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) {
|
||||
return Colors.blueGrey;
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ class _UserPageState extends State<UserPage> {
|
||||
Future<void> _loadRadarData() async {
|
||||
final provider = Provider.of<UserProvider>(context, listen: false);
|
||||
try {
|
||||
final data = await provider.fetchRadarData("684a6ee7f62aed83538ded34");
|
||||
final data = await provider.fetchRadarData(provider.user?.id ?? 'default_id');
|
||||
setState(() {
|
||||
_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) {
|
||||
return _webCard(
|
||||
child: Column(
|
||||
@@ -1148,6 +1265,12 @@ class _UserPageState extends State<UserPage> {
|
||||
const SizedBox(width: 10),
|
||||
Expanded(child: Text(card.segaId ?? "")),
|
||||
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(
|
||||
onPressed: () => _verifyBoundSega(card),
|
||||
child: const Text("验证", style: TextStyle(fontSize: 12)),
|
||||
|
||||
@@ -47,7 +47,6 @@ class RecommendationHelper {
|
||||
|
||||
// 提取关键字段
|
||||
final int musicId = detail['musicId'] ?? detail['id'] ?? 0;
|
||||
if(musicId>16000) continue;
|
||||
final int level = detail['level'] ?? detail['levelIndex'] ?? 3; // 默认 Master
|
||||
final int achievement = detail['achievement'] ?? 0;
|
||||
// 确保 rating 是 double
|
||||
@@ -100,6 +99,7 @@ class RecommendationHelper {
|
||||
for (var song in allSongs) {
|
||||
// 过滤无效 ID
|
||||
if (song.id < 100) continue;
|
||||
if (song.id > 16000) continue;
|
||||
|
||||
// 获取 Master (Level 3) 的定数,如果没有则获取 Expert (Level 2)
|
||||
double? masterLevel = _getSongLevel(song, 3);
|
||||
|
||||
@@ -6,9 +6,13 @@
|
||||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <open_file_linux/open_file_linux_plugin.h>
|
||||
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||
|
||||
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 =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
|
||||
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
open_file_linux
|
||||
url_launcher_linux
|
||||
)
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import FlutterMacOS
|
||||
import Foundation
|
||||
|
||||
import geolocator_apple
|
||||
import open_file_mac
|
||||
import package_info_plus
|
||||
import share_plus
|
||||
import shared_preferences_foundation
|
||||
@@ -13,6 +14,7 @@ import url_launcher_macos
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
|
||||
OpenFilePlugin.register(with: registry.registrar(forPlugin: "OpenFilePlugin"))
|
||||
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
|
||||
64
pubspec.lock
@@ -536,6 +536,70 @@ packages:
|
||||
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -60,12 +60,14 @@ dev_dependencies:
|
||||
fl_chart: ^0.68.0
|
||||
url_launcher: ^6.2.2
|
||||
share_plus: ^7.2.2
|
||||
open_file: ^3.3.2
|
||||
# For information on the generic Dart part of this file, see the
|
||||
# following page: https://dart.dev/tools/pub/pubspec
|
||||
|
||||
# The following section is specific to Flutter packages.
|
||||
flutter:
|
||||
|
||||
assets:
|
||||
- images/
|
||||
# The following line ensures that the Material Icons font is
|
||||
# included with your application, so that you can use the icons in
|
||||
# the material Icons class.
|
||||
|
||||