920 lines
33 KiB
Dart
920 lines
33 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;
|
||
String? _filterFromVersion;
|
||
String? _filterGenre;
|
||
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);
|
||
}
|
||
}
|
||
|
||
// 加载用户成绩 —— ✅ 修复 1:直接用完整ID存储
|
||
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;
|
||
}
|
||
|
||
if (_filterFromVersion != null && song.from != _filterFromVersion) return false;
|
||
if (_filterGenre != null && song.genre != _filterGenre) 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;
|
||
_filterFromVersion = null;
|
||
_filterGenre = null;
|
||
_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(),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
@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: [
|
||
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: (v) {
|
||
setState(() => _filterFromVersion = v);
|
||
_applyFilters();
|
||
},
|
||
),
|
||
),
|
||
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: (v) {
|
||
setState(() => _filterGenre = v);
|
||
_applyFilters();
|
||
},
|
||
),
|
||
),
|
||
],
|
||
),
|
||
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 || _filterFromVersion != null || _filterGenre != null || _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;
|
||
|
||
// --- 新增:处理别名逻辑 ---
|
||
// 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 += " ...";
|
||
}
|
||
}
|
||
// -------------------------
|
||
|
||
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),
|
||
// 这一行:id + 左侧标签
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.end,
|
||
crossAxisAlignment: CrossAxisAlignment.center,
|
||
children: [
|
||
// 标签区域(cn / jp / m2l / long)
|
||
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,
|
||
),
|
||
|
||
),
|
||
|
||
// id 文本
|
||
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.min 配合 Spacer
|
||
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) {
|
||
// 可选:根据剩余宽度动态决定显示多少行,这里简单处理为最多2行
|
||
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),
|
||
// 阴影:只有传入 shadowColor 才显示
|
||
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);
|
||
|
||
// 按 type 分组:SD / DX / UT
|
||
final Map<String, List<dynamic>> typeGroups = {};
|
||
for (var item in diffs) {
|
||
final type = item['type'] as String;
|
||
typeGroups.putIfAbsent(type, () => []);
|
||
typeGroups[type]!.add(item);
|
||
}
|
||
|
||
// 固定顺序:SD → DX → UT
|
||
final order = ['SD', 'DX', 'UT'];
|
||
List<Widget> rows = [];
|
||
|
||
for (final type in order) {
|
||
final items = typeGroups[type];
|
||
if (items == null || items.isEmpty) continue;
|
||
|
||
// 每一个类型 = 一行 Wrap
|
||
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;
|
||
|
||
// 多UT按ID显示对应名称
|
||
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: 5, vertical: 2),
|
||
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: 10,
|
||
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";
|
||
}
|
||
}
|
||
}
|