1013 lines
35 KiB
Dart
1013 lines
35 KiB
Dart
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";
|
||
}
|
||
}
|
||
} |