Files
UnionApp/lib/pages/music/music_page.dart
spasolreisa 74e47971ca 0419 0318
更新4
2026-04-19 03:18:42 +08:00

1013 lines
35 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:unionapp/pages/music/score_single.dart';
import '../../../model/song_model.dart';
import '../../../providers/user_provider.dart';
import '../../../service/song_service.dart';
import '../../../service/user_service.dart';
class MusicPage extends StatefulWidget {
const MusicPage({super.key});
@override
State<MusicPage> createState() => _MusicPageState();
}
class _MusicPageState extends State<MusicPage> with SingleTickerProviderStateMixin {
bool _isLoading = true;
bool _isRefreshing = false;
String _errorMessage = '';
List<SongModel> _allSongs = [];
List<SongModel> _displaySongs = [];
// 筛选
String _searchQuery = '';
int? _filterLevelType;
// --- 修改点 1: 将单选 String? 改为多选 Set<String> ---
Set<String> _filterVersions = {};
Set<String> _filterGenres = {};
double? _selectedMinLevel;
double? _selectedMaxLevel;
bool _isAdvancedFilterExpanded = false;
late AnimationController _animationController;
late Animation<double> _animation;
static const List<double> _levelOptions = [
1.0, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0, 4.5, 5.0, 5.5,
6.0, 6.5, 7.0, 7.5, 8.0, 8.5, 9.0, 9.5, 10.0, 10.5,
11.0, 11.5, 12.0, 12.5,
13.0, 13.1, 13.2, 13.3, 13.4, 13.5, 13.6, 13.7, 13.8, 13.9,
14.0, 14.1, 14.2, 14.3, 14.4, 14.5, 14.6, 14.7, 14.8, 14.9,
15.0,
];
late FixedExtentScrollController _minLevelScrollController;
late FixedExtentScrollController _maxLevelScrollController;
Set<String> _availableVersions = {};
Set<String> _availableGenres = {};
// 用户成绩缓存key = 真实完整 musicId
Map<int, Map<int, dynamic>> _userScoreCache = {};
@override
void initState() {
super.initState();
_animationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
);
_animation = CurvedAnimation(
parent: _animationController,
curve: Curves.easeInOut,
);
_minLevelScrollController = FixedExtentScrollController(initialItem: 0);
_maxLevelScrollController = FixedExtentScrollController(initialItem: _levelOptions.length - 1);
_loadAllSongs();
_loadUserScores();
UserProvider.instance.addListener(_onUserChanged);
}
void _onUserChanged() {
if (mounted) {
_loadUserScores();
}
}
@override
void dispose() {
UserProvider.instance.removeListener(_onUserChanged);
_animationController.dispose();
_minLevelScrollController.dispose();
_maxLevelScrollController.dispose();
super.dispose();
}
// 加载所有歌曲
Future<void> _loadAllSongs() async {
setState(() => _isLoading = true);
try {
final songs = await SongService.getAllSongs();
_allSongs = songs;
final uniqueSongs = <int, SongModel>{};
for (final song in songs) {
uniqueSongs.putIfAbsent(song.id, () => song);
}
_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();
setState(() => _errorMessage = '');
} catch (e) {
setState(() => _errorMessage = e.toString());
} finally {
setState(() => _isLoading = false);
}
}
// 加载用户成绩
Future<void> _loadUserScores() async {
try {
final userProvider = UserProvider.instance;
final token = userProvider.token;
if (token == null) return;
final userName = userProvider.selectedCnUserName;
Map<String, dynamic> scoreData;
if (userProvider.scoreDataSource == 'sega') {
final segaId = userProvider.selectedSegaId;
if (segaId == null || segaId.isEmpty) return;
scoreData = await UserService.getSegaRatingData(token, segaId);
} else {
scoreData = await SongService.getUserAllScores(token, name: userName);
}
List<dynamic> userMusicList = [];
if (scoreData.containsKey('userScoreAll_')) {
userMusicList = scoreData['userScoreAll_']['userMusicList'] ?? [];
} else if (scoreData.containsKey('userMusicList')) {
userMusicList = scoreData['userMusicList'] ?? [];
} else if (scoreData.containsKey('segaId2chartlist')) {
final segaId = userProvider.selectedSegaId;
final map = scoreData['segaId2chartlist'] ?? {};
userMusicList = (map[segaId] as List?) ?? [];
}
_userScoreCache.clear();
for (var group in userMusicList) {
final details = group['userMusicDetailList'] ?? [];
for (var d in details) {
int realMusicId = d['musicId']; // 完整ID
int level = d['level'] ?? 0;
_userScoreCache[realMusicId] ??= {};
_userScoreCache[realMusicId]![level] = d;
}
}
setState(() {});
} catch (e) {
print("加载成绩失败: $e");
}
}
// 搜索 + 过滤
void _applyFilters() {
final q = _normalizeSearch(_searchQuery);
_displaySongs = _allSongs.where((song) {
if (q.isNotEmpty) {
bool match = false;
if (_normalizeSearch(song.title).contains(q)) match = true;
if (_normalizeSearch(song.artist).contains(q)) match = true;
if (song.id.toString().contains(q)) match = true;
for (var a in song.albums) {
if (_normalizeSearch(a).contains(q)) match = true;
}
if (!match) 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;
final allDiffs = _getAllDifficultiesWithType(song);
for (var d in allDiffs) {
if (d['diff']['level_id'] == _filterLevelType) {
hasLevel = true;
break;
}
}
if (!hasLevel) return false;
}
if (_selectedMinLevel != null || _selectedMaxLevel != null) {
bool inRange = false;
final allDiffs = _getAllDifficultiesWithType(song);
for (var d in allDiffs) {
final lv = double.tryParse(d['diff']['level_value']?.toString() ?? '');
if (lv == null) continue;
final minOk = _selectedMinLevel == null || lv >= _selectedMinLevel!;
final maxOk = _selectedMaxLevel == null || lv <= _selectedMaxLevel!;
if (minOk && maxOk) {
inRange = true;
break;
}
}
if (!inRange) return false;
}
return true;
}).toList();
setState(() {});
}
String _normalizeSearch(String? s) {
if (s == null) return '';
return s.trim().toLowerCase();
}
// 带类型的难度SD/DX/UT
List<Map<String, dynamic>> _getAllDifficultiesWithType(SongModel song) {
List<Map<String, dynamic>> all = [];
if (song.sd != null && song.sd!.isNotEmpty) {
for (var d in song.sd!.values) {
all.add({'type': 'SD', 'diff': d});
}
}
if (song.dx != null && song.dx!.isNotEmpty) {
for (var d in song.dx!.values) {
all.add({'type': 'DX', 'diff': d});
}
}
if (song.ut != null && song.ut!.isNotEmpty) {
for (var d in song.ut!.values) {
all.add({'type': 'UT', 'diff': d});
}
}
return all;
}
// 根据歌曲类型获取真实完整ID
int _getRealMusicId(SongModel song, String type) {
if (type == "SD") {
return song.id;
} else if (type == "DX") {
return 10000 + song.id;
} else {
return 100000 + song.id;
}
}
void _resetFilters() {
setState(() {
_searchQuery = '';
_filterLevelType = null;
_filterVersions.clear(); // 清空集合
_filterGenres.clear(); // 清空集合
_selectedMinLevel = null;
_selectedMaxLevel = null;
});
_minLevelScrollController.jumpToItem(0);
_maxLevelScrollController.jumpToItem(_levelOptions.length - 1);
_applyFilters();
}
String _getLevelRangeText() {
if (_selectedMinLevel == null && _selectedMaxLevel == null) return "全部";
final min = _selectedMinLevel?.toStringAsFixed(1) ?? "1.0";
final max = _selectedMaxLevel?.toStringAsFixed(1) ?? "15.0";
return "$min ~ $max";
}
void _showLevelPickerDialog() {
int minIdx = _selectedMinLevel != null ? _levelOptions.indexOf(_selectedMinLevel!) : 0;
int maxIdx = _selectedMaxLevel != null ? _levelOptions.indexOf(_selectedMaxLevel!) : _levelOptions.length - 1;
showModalBottomSheet(
context: context,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))),
builder: (context) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_minLevelScrollController.jumpToItem(minIdx);
_maxLevelScrollController.jumpToItem(maxIdx);
});
return Container(
height: 300,
padding: const EdgeInsets.only(top: 16),
child: Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
TextButton(
onPressed: () {
setState(() {
_selectedMinLevel = null;
_selectedMaxLevel = null;
});
Navigator.pop(context);
},
child: const Text("重置", style: TextStyle(color: Colors.red)),
),
const Text("定数范围", style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
TextButton(
onPressed: () {
final min = _levelOptions[_minLevelScrollController.selectedItem];
final max = _levelOptions[_maxLevelScrollController.selectedItem];
setState(() {
_selectedMinLevel = min;
_selectedMaxLevel = max;
_applyFilters();
});
Navigator.pop(context);
},
child: const Text("确定", style: TextStyle(fontWeight: FontWeight.bold)),
),
],
),
),
const Divider(),
Expanded(
child: Row(
children: [
Expanded(
child: Column(
children: [
const Text("最小定数", style: TextStyle(fontSize: 12, color: Colors.grey)),
Expanded(
child: CupertinoPicker(
scrollController: _minLevelScrollController,
itemExtent: 40,
onSelectedItemChanged: (_) {},
children: _levelOptions.map((e) => Center(child: Text(e.toStringAsFixed(1)))).toList(),
),
),
],
),
),
Expanded(
child: Column(
children: [
const Text("最大定数", style: TextStyle(fontSize: 12, color: Colors.grey)),
Expanded(
child: CupertinoPicker(
scrollController: _maxLevelScrollController,
itemExtent: 40,
onSelectedItemChanged: (_) {},
children: _levelOptions.map((e) => Center(child: Text(e.toStringAsFixed(1)))).toList(),
),
),
],
),
),
],
),
),
],
),
);
},
);
}
// --- 新增:多选 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(
appBar: AppBar(
title: const Text("歌曲列表"),
actions: [
if (_isRefreshing)
const Padding(
padding: EdgeInsets.all(8.0),
child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)),
)
else
IconButton(icon: const Icon(Icons.refresh), onPressed: () async {
setState(() => _isRefreshing = true);
await _loadAllSongs();
await _loadUserScores();
setState(() => _isRefreshing = false);
})
],
),
body: Column(
children: [
_buildFilterBar(),
Expanded(child: _buildBody()),
],
),
);
}
Widget _buildFilterBar() {
return Container(
color: Theme.of(context).cardColor,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 8),
child: Row(
children: [
Expanded(
child: TextField(
decoration: InputDecoration(
hintText: "搜索歌名/别名/艺术家/ID",
prefixIcon: const Icon(Icons.search),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(25)),
contentPadding: const EdgeInsets.symmetric(vertical: 0, horizontal: 16),
isDense: true,
),
onChanged: (v) {
setState(() => _searchQuery = v);
_applyFilters();
},
),
),
IconButton(
icon: Icon(_isAdvancedFilterExpanded ? Icons.expand_less : Icons.expand_more),
onPressed: () {
setState(() {
_isAdvancedFilterExpanded = !_isAdvancedFilterExpanded;
_isAdvancedFilterExpanded ? _animationController.forward() : _animationController.reverse();
});
},
),
if (_hasFilters())
IconButton(icon: const Icon(Icons.clear, color: Colors.redAccent), onPressed: _resetFilters),
],
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: Row(
children: [
Expanded(
child: _buildDropdown(
label: "难度类型",
value: _filterLevelType,
items: const [
DropdownMenuItem(value: null, child: Text("全部")),
DropdownMenuItem(value: 0, child: Text("Basic")),
DropdownMenuItem(value: 1, child: Text("Advanced")),
DropdownMenuItem(value: 2, child: Text("Expert")),
DropdownMenuItem(value: 3, child: Text("Master")),
DropdownMenuItem(value: 4, child: Text("Re:Master")),
],
onChanged: (v) {
setState(() => _filterLevelType = v);
_applyFilters();
},
),
),
],
),
),
SizeTransition(
sizeFactor: _animation,
axisAlignment: -1,
child: Container(
padding: const EdgeInsets.all(12),
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: _buildMultiSelectChip(
label: "版本",
selectedValues: _filterVersions,
allOptions: _availableVersions,
onSelectionChanged: (newSet) {
_filterVersions = newSet;
},
),
),
const SizedBox(width: 8),
Expanded(
child: _buildMultiSelectChip(
label: "流派",
selectedValues: _filterGenres,
allOptions: _availableGenres,
onSelectionChanged: (newSet) {
_filterGenres = newSet;
},
),
),
],
),
const SizedBox(height: 8),
InkWell(
onTap: _showLevelPickerDialog,
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: [
const Text("定数范围", style: TextStyle(fontWeight: FontWeight.bold)),
Text(_getLevelRangeText(), style: TextStyle(color: Colors.grey[600])),
const Icon(Icons.arrow_drop_down, color: Colors.grey),
],
),
),
),
],
),
),
),
],
),
);
}
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,
isExpanded: true,
decoration: InputDecoration(labelText: label, border: const OutlineInputBorder(), contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), isDense: false),
items: items,
onChanged: onChanged,
);
}
bool _hasFilters() =>
_searchQuery.isNotEmpty ||
_filterLevelType != null ||
_filterVersions.isNotEmpty || // 修改判断逻辑
_filterGenres.isNotEmpty || // 修改判断逻辑
_selectedMinLevel != null ||
_selectedMaxLevel != null;
Widget _buildBody() {
if (_isLoading) return const Center(child: CircularProgressIndicator());
if (_errorMessage.isNotEmpty) return Center(child: Text(_errorMessage, style: const TextStyle(color: Colors.red)));
if (_displaySongs.isEmpty) return const Center(child: Text("无匹配歌曲"));
return ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
itemCount: _displaySongs.length,
itemBuilder: (_, idx) {
final song = _displaySongs[idx];
return _songItem(song);
},
);
}
Widget _songItem(SongModel song) {
final cover = _getCoverUrl(song.id);
bool hasScore = false;
// 检查SD/DX/UT任意一个有成绩
if (song.sd != null && _userScoreCache.containsKey(song.id)) hasScore = true;
if (song.dx != null && _userScoreCache.containsKey(10000 + song.id)) hasScore = true;
if (song.ut != null && _userScoreCache.containsKey(100000 + song.id)) hasScore = true;
// --- 新增:处理别名逻辑 ---
List<String> rawAliases = song.albums ?? [];
final seen = <String>{};
final uniqueAliases = rawAliases.where((alias) => seen.add(alias)).toList();
final filteredAliases = uniqueAliases.where((alias) =>
alias != song.title &&
alias != song.artist
).toList();
String aliasDisplayText = "";
if (filteredAliases.isNotEmpty) {
int maxAliasCount = 6;
List<String> displayList = filteredAliases.take(maxAliasCount).toList();
aliasDisplayText = displayList.join(" · ");
if (filteredAliases.length > maxAliasCount) {
aliasDisplayText += " ...";
}
}
// -------------------------
return Card(
margin: const EdgeInsets.only(bottom: 10),
elevation: 4,
shadowColor: Colors.purpleAccent.withOpacity(0.5),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: InkWell(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => SongDetailPage(
song: song,
userScoreCache: _userScoreCache,
)),
);
},
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 左侧:封面 + ID 区域
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(2),
child: Image.network(
cover,
width: 100,
height: 100,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Container(
width: 60,
height: 60,
color: Colors.grey[200],
child: const Icon(Icons.music_note),
),
),
),
const SizedBox(height: 2),
Row(
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (song.cn == true)
_buildTag(
"CN",
backgroundColor: Colors.redAccent,
shadowColor: Colors.red.withOpacity(0.3),
),
if (song.jp == true)
_buildTag(
"JP",
backgroundColor: Colors.blueAccent,
shadowColor: Colors.red.withOpacity(0.3),
),
if (song.m2l == true)
_buildTag(
"M2L",
gradient: const LinearGradient(
colors: [Colors.purple, Colors.blueAccent],
begin: Alignment.centerLeft,
end: Alignment.centerRight,
),
),
if (song.long == true)
_buildTag(
"LONG",
gradient: const LinearGradient(
colors: [Colors.black12, Colors.lightBlueAccent],
begin: Alignment.topRight,
end: Alignment.bottomLeft,
),
),
Text(
" ${song.id}",
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 11,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
SizedBox(
width: 100,
child: Text(
getLeftEllipsisText("${song.from}", 15),
style: const TextStyle(
fontSize: 9,
color: Colors.grey,
),
maxLines: 1,
textAlign: TextAlign.right,
overflow: TextOverflow.visible,
),
)
],
),
const SizedBox(width: 12),
// 右侧:详细信息
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
song.title,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
song.artist ?? "Unknown",
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 6),
..._getDifficultyChipsByType(song),
if (aliasDisplayText.isNotEmpty) ...[
const SizedBox(height: 6),
LayoutBuilder(
builder: (context, constraints) {
return Text(
aliasDisplayText,
style: TextStyle(
fontSize: 10,
color: Colors.grey[500],
height: 1.2,
),
maxLines: 5,
overflow: TextOverflow.ellipsis,
);
},
),
],
],
),
),
if (hasScore)
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
decoration: BoxDecoration(color: Colors.greenAccent.withOpacity(0.15), borderRadius: BorderRadius.circular(4)),
child: const Text("Score", style: TextStyle(color: Colors.green, fontSize: 12, fontWeight: FontWeight.bold)),
),
],
),
),
),
);
}
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,
}) {
return Container(
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),
boxShadow: shadowColor != null
? [
BoxShadow(
color: shadowColor,
blurRadius: shadowBlurRadius,
offset: shadowOffset,
)
]
: null,
),
child: Text(
text,
style: TextStyle(
color: textColor,
fontSize: fontSize,
fontWeight: FontWeight.bold,
),
),
);
}
List<Widget> _getDifficultyChipsByType(SongModel song) {
final diffs = _getAllDifficultiesWithType(song);
final Map<String, List<dynamic>> typeGroups = {};
for (var item in diffs) {
final type = item['type'] as String;
typeGroups.putIfAbsent(type, () => []);
typeGroups[type]!.add(item);
}
final order = ['SD', 'DX', 'UT'];
List<Widget> rows = [];
for (final type in order) {
final items = typeGroups[type];
if (items == null || items.isEmpty) continue;
final row = Wrap(
spacing: 6,
runSpacing: 4,
children: items.map<Widget>((item) {
final diff = item['diff'] as Map;
final lid = diff['level_id'] as int;
final lvValue = double.tryParse(diff['level_value']?.toString() ?? '') ?? 0;
bool isBanquet = lvValue == 10.0 || song.id > 100000 || type == 'UT';
int realId = _getRealMusicId(song, type);
bool hasScore = _userScoreCache[realId]?[lid] != null;
Color color = Colors.grey;
String label = "";
if (isBanquet) {
color = Colors.pinkAccent;
if (type == 'UT') {
final utTitleMap = song.utTitle as Map?;
if (utTitleMap != null && utTitleMap.isNotEmpty) {
final key = item["diff"]['id'].toString();
if (utTitleMap.containsKey(key)) {
label = utTitleMap[key].toString();
} else {
label = "UT";
}
} else {
label = "UT";
}
} else {
label = type;
}
} else {
switch (lid) {
case 0:
color = Colors.green;
label = type;
break;
case 1:
color = Colors.yellow.shade700;
label = type;
break;
case 2:
color = Colors.red;
label = type;
break;
case 3:
color = Colors.purple;
label = type;
break;
case 4:
color = Colors.purpleAccent.shade100;
label = type;
break;
default:
return const SizedBox();
}
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 1),
decoration: BoxDecoration(
color: color.withOpacity(hasScore ? 0.25 : 0.1),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: color.withOpacity(0.5)),
),
child: Text(
isBanquet ? label : "$label ${lvValue.toStringAsFixed(1)}",
style: TextStyle(
color: color,
fontSize: 9,
fontWeight: FontWeight.bold,
),
),
);
}).toList(),
);
rows.add(row);
rows.add(const SizedBox(height: 4));
}
return rows;
}
String _getCoverUrl(int musicId) {
int displayId = musicId % 10000;
if (musicId >= 16000 && musicId <= 20000) {
String idStr = displayId.toString().padLeft(6, '0');
return "https://u.mai2.link/jacket/UI_Jacket_$idStr.jpg";
} else {
return "https://cdn.godserver.cn/resource/static/mai/cover/$displayId.png";
}
}
}