1563 lines
57 KiB
Dart
1563 lines
57 KiB
Dart
import 'dart:io';
|
||
import 'dart:math';
|
||
import 'package:flutter/cupertino.dart';
|
||
import 'package:flutter/material.dart';
|
||
import '../../model/song_model.dart';
|
||
import '../../providers/user_provider.dart';
|
||
import '../../service/song_service.dart';
|
||
import '../../service/user_service.dart';
|
||
import 'package:share_plus/share_plus.dart';
|
||
import 'package:dio/dio.dart';
|
||
import 'package:path_provider/path_provider.dart';
|
||
|
||
import '../../tool/cacheImage.dart';
|
||
import '../../widgets/glowBlobConfig.dart';
|
||
|
||
class ScorePage extends StatefulWidget {
|
||
const ScorePage({Key? key}) : super(key: key);
|
||
|
||
@override
|
||
State<ScorePage> createState() => _ScorePageState();
|
||
}
|
||
|
||
class _ScorePageState extends State<ScorePage> with SingleTickerProviderStateMixin {
|
||
bool _isLoading = true;
|
||
bool _isRefreshing = false;
|
||
String _errorMessage = '';
|
||
|
||
List<SongModel> _allSongs = [];
|
||
Map<int, SongModel> _songMap = {};
|
||
List<dynamic> _userMusicList = [];
|
||
|
||
// --- 搜索与筛选状态 ---
|
||
String _searchQuery = '';
|
||
int? _filterLevelType;
|
||
int? _filterRank;
|
||
bool _isAdvancedFilterExpanded = false;
|
||
double? _minAchievement;
|
||
late final TextEditingController _minAchievementController;
|
||
// 改为多选
|
||
List<String> _filterFromVersions = [];
|
||
List<String> _filterGenres = [];
|
||
double? _selectedMinLevel;
|
||
double? _selectedMaxLevel;
|
||
int? _filterComboStatus;
|
||
int? _filterSyncStatus;
|
||
|
||
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 = {};
|
||
late AnimationController _animationController;
|
||
late Animation<double> _animation;
|
||
|
||
String get _currentDataSource => UserProvider.instance.scoreDataSource;
|
||
String? get _currentSegaId => UserProvider.instance.selectedSegaId;
|
||
String? get _currentCnUserName => UserProvider.instance.selectedCnUserName;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_animationController = AnimationController(
|
||
vsync: this,
|
||
duration: const Duration(milliseconds: 300),
|
||
);
|
||
_animation = CurvedAnimation(
|
||
parent: _animationController,
|
||
curve: Curves.easeInOut,
|
||
);
|
||
|
||
_minAchievementController = TextEditingController();
|
||
_minLevelScrollController = FixedExtentScrollController(initialItem: 0);
|
||
_maxLevelScrollController = FixedExtentScrollController(initialItem: _levelOptions.length - 1);
|
||
|
||
UserProvider.instance.addListener(_onProviderChanged);
|
||
_loadData(isInitialLoad: true);
|
||
}
|
||
|
||
void _onProviderChanged() {
|
||
if (mounted) {
|
||
_loadData(isInitialLoad: true);
|
||
}
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
UserProvider.instance.removeListener(_onProviderChanged);
|
||
_minAchievementController.dispose();
|
||
_minLevelScrollController.dispose();
|
||
_maxLevelScrollController.dispose();
|
||
_animationController.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
String _normalizeString(String? str) {
|
||
if (str == null) return '';
|
||
return str.replaceAll(' ', '').replaceAll('\u3000', '').toLowerCase();
|
||
}
|
||
|
||
int _normalizeSongId(int musicId) {
|
||
if (musicId < 10000) return musicId;
|
||
if (musicId >= 100000) {
|
||
int candidate = musicId - 100000;
|
||
if (candidate > 0 && candidate < 10000) return candidate;
|
||
return musicId % 10000;
|
||
}
|
||
if (musicId >= 10000 && musicId < 100000) {
|
||
return musicId % 10000;
|
||
}
|
||
return musicId;
|
||
}
|
||
|
||
Map<String, dynamic> _getSongDisplayInfo(int musicId) {
|
||
int standardId = _normalizeSongId(musicId);
|
||
SongModel? song = _songMap[standardId];
|
||
String? displayTitle;
|
||
|
||
if (song == null) song = _songMap[musicId];
|
||
if (song == null && musicId >= 10000) song = _songMap[musicId - 10000];
|
||
|
||
if (song != null) {
|
||
if (musicId >= 100000 && song.utTitle != null) {
|
||
if (song.utTitle is String) {
|
||
displayTitle = song.utTitle as String;
|
||
} else if (song.utTitle is Map) {
|
||
final map = song.utTitle as Map;
|
||
displayTitle = map['ultra'] ?? map['tera'] ?? map.values.first.toString();
|
||
}
|
||
}
|
||
displayTitle ??= song.title;
|
||
} else {
|
||
displayTitle = "Unknown Song ($musicId)";
|
||
}
|
||
|
||
return {'song': song, 'title': displayTitle, 'standardId': standardId};
|
||
}
|
||
|
||
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";
|
||
}
|
||
}
|
||
|
||
Future<void> _loadData({bool isInitialLoad = false}) async {
|
||
// 首次加载才显示加载圈
|
||
if (isInitialLoad) {
|
||
setState(() { _isLoading = true; });
|
||
} else {
|
||
setState(() { _isRefreshing = true; });
|
||
}
|
||
|
||
try {
|
||
final userProvider = UserProvider.instance;
|
||
|
||
// ==============================================
|
||
// 【关键优化】等待 UserProvider 真正初始化完成
|
||
// 避免刚进页面就判断 token 导致的“太快报错”
|
||
// ==============================================
|
||
await userProvider.waitInit(); // 你需要在 UserProvider 里加这个方法(我下面会给你代码)
|
||
|
||
// 现在再获取 token,此时状态已经稳定
|
||
final token = userProvider.token;
|
||
|
||
if (token == null || token.isEmpty) {
|
||
debugPrint("ℹ️ 用户未登录,清空用户数据");
|
||
// setState(() {
|
||
// _userMusicList = [];
|
||
// _errorMessage = '';
|
||
// });
|
||
return; // 温和退出,不弹错误
|
||
}
|
||
|
||
debugPrint("✅ 用户已登录,开始加载数据");
|
||
|
||
// 只有首次加载 / 数据为空时才拉全量歌曲列表
|
||
if (_allSongs.isEmpty || isInitialLoad) {
|
||
debugPrint("ℹ️ 加载全量歌曲列表");
|
||
final songs = await SongService.getAllSongs();
|
||
_allSongs = songs;
|
||
_songMap = {for (var song in songs) song.id: song};
|
||
_availableVersions = songs.map((s) => s.from).where((v) => v.isNotEmpty).toSet();
|
||
_availableGenres = songs.map((s) => s.genre).where((g) => g.isNotEmpty).toSet();
|
||
}
|
||
|
||
if (_currentDataSource == 'sega') {
|
||
final segaId = _currentSegaId;
|
||
if (segaId == null || segaId.isEmpty) {
|
||
throw "请选择一个有效的 Sega ID";
|
||
}
|
||
debugPrint("ℹ️ 加载 Sega 评分数据");
|
||
final rawData = await UserService.getSegaRatingData(token, segaId);
|
||
|
||
final segaId2chartlist = rawData['segaId2chartlist'] as Map<String, dynamic>?;
|
||
List<dynamic> rawDetails = [];
|
||
if (segaId2chartlist != null && segaId2chartlist.containsKey(segaId)) {
|
||
final dynamic content = segaId2chartlist[segaId];
|
||
if (content is List) {
|
||
rawDetails = content;
|
||
}
|
||
}
|
||
|
||
Map<int, List<dynamic>> groupedByMusicId = {};
|
||
for (var detail in rawDetails) {
|
||
if (detail is Map && detail.containsKey('musicId')) {
|
||
int mId = detail['musicId'];
|
||
if (!groupedByMusicId.containsKey(mId)) {
|
||
groupedByMusicId[mId] = [];
|
||
}
|
||
groupedByMusicId[mId]!.add(detail);
|
||
}
|
||
}
|
||
|
||
_userMusicList = groupedByMusicId.entries.map((entry) {
|
||
return {
|
||
'userMusicDetailList': entry.value,
|
||
'referenceMusicId': entry.key
|
||
};
|
||
}).toList();
|
||
|
||
} else {
|
||
debugPrint("ℹ️ 加载用户评分数据");
|
||
final scoreData = await SongService.getUserAllScores(token, name: _currentCnUserName);
|
||
|
||
if (scoreData.containsKey('userScoreAll_')) {
|
||
_userMusicList = scoreData['userScoreAll_']['userMusicList'] ?? [];
|
||
} else if (scoreData.containsKey('userMusicList')) {
|
||
_userMusicList = scoreData['userMusicList'] ?? [];
|
||
} else {
|
||
_userMusicList = [];
|
||
}
|
||
}
|
||
|
||
setState(() { _errorMessage = ''; });
|
||
debugPrint("✅ 数据加载完成");
|
||
|
||
} catch (e) {
|
||
debugPrint("❌ Load Data Error: $e");
|
||
if (isInitialLoad) {
|
||
setState(() { _errorMessage = e.toString(); });
|
||
} else {
|
||
if (mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(content: Text("刷新失败: $e"), duration: const Duration(seconds: 2)),
|
||
);
|
||
}
|
||
}
|
||
} finally {
|
||
if (mounted) {
|
||
setState(() {
|
||
_isLoading = false;
|
||
_isRefreshing = false;
|
||
});
|
||
}
|
||
}
|
||
}
|
||
List<dynamic> get _filteredMusicList {
|
||
bool isNoFilter = _searchQuery.isEmpty &&
|
||
_filterLevelType == null &&
|
||
_filterRank == null &&
|
||
_filterFromVersions.isEmpty &&
|
||
_filterGenres.isEmpty &&
|
||
_selectedMinLevel == null &&
|
||
_selectedMaxLevel == null &&
|
||
_minAchievement == null &&
|
||
_filterComboStatus == null &&
|
||
_filterSyncStatus == null;
|
||
|
||
List<dynamic> result;
|
||
|
||
if (isNoFilter) {
|
||
result = List.from(_userMusicList);
|
||
} else {
|
||
final normalizedQuery = _normalizeString(_searchQuery);
|
||
double? minAchVal = _minAchievement;
|
||
|
||
result = _userMusicList.where((group) {
|
||
final details = group['userMusicDetailList'] as List? ?? [];
|
||
if (details.isEmpty) return false;
|
||
|
||
final firstDetail = details[0];
|
||
final int rawMusicId = firstDetail['musicId'];
|
||
|
||
final info = _getSongDisplayInfo(rawMusicId);
|
||
final SongModel? song = info['song'] as SongModel?;
|
||
|
||
if (_searchQuery.isNotEmpty) {
|
||
bool matchesSearch = false;
|
||
final int stdId = info['standardId'] as int;
|
||
final String idSearchStr = "$rawMusicId $stdId";
|
||
if (idSearchStr.contains(_searchQuery)) matchesSearch = true;
|
||
|
||
String titleToSearch = info['title'] as String;
|
||
if (song == null && firstDetail.containsKey('musicName')) {
|
||
titleToSearch = firstDetail['musicName'] ?? titleToSearch;
|
||
}
|
||
final String normTitle = _normalizeString(titleToSearch);
|
||
if (normTitle.contains(normalizedQuery)) matchesSearch = true;
|
||
|
||
final String normArtist = _normalizeString(song?.artist);
|
||
if (!matchesSearch && normArtist.contains(normalizedQuery)) matchesSearch = true;
|
||
|
||
if (!matchesSearch && song != null) {
|
||
for (var alias in song.albums) {
|
||
if (_normalizeString(alias).contains(normalizedQuery)) {
|
||
matchesSearch = true;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!matchesSearch && firstDetail.containsKey('alias')) {
|
||
final dynamic aliasData = firstDetail['alias'];
|
||
if (aliasData is List) {
|
||
for (var a in aliasData) {
|
||
if (a != null && _normalizeString(a.toString()).contains(normalizedQuery)) {
|
||
matchesSearch = true;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!matchesSearch) return false;
|
||
}
|
||
|
||
if (song != null) {
|
||
// 版本多选过滤
|
||
if (_filterFromVersions.isNotEmpty && !_filterFromVersions.contains(song.from)) return false;
|
||
// 流派多选过滤
|
||
if (_filterGenres.isNotEmpty && !_filterGenres.contains(song.genre)) return false;
|
||
} else {
|
||
if (_filterFromVersions.isNotEmpty || _filterGenres.isNotEmpty) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
bool hasMatchingDetail = details.any((detail) {
|
||
if (_filterLevelType != null && detail['level'] != _filterLevelType) return false;
|
||
if (_filterRank != null && detail['scoreRank'] != _filterRank) return false;
|
||
|
||
double currentLevel = (detail['level_info'] ?? 0).toDouble();
|
||
if (_selectedMinLevel != null && currentLevel < _selectedMinLevel!) return false;
|
||
if (_selectedMaxLevel != null && currentLevel > _selectedMaxLevel!) return false;
|
||
|
||
double currentAch = (detail['achievement'] ?? 0).toDouble();
|
||
if (minAchVal != null) {
|
||
if (currentAch / 10000 < minAchVal) return false;
|
||
}
|
||
|
||
if (_filterComboStatus != null && detail['comboStatus'] != _filterComboStatus) return false;
|
||
if (_filterSyncStatus != null && detail['syncStatus'] != _filterSyncStatus) return false;
|
||
|
||
return true;
|
||
});
|
||
|
||
return hasMatchingDetail;
|
||
}).toList();
|
||
}
|
||
|
||
result.sort((a, b) {
|
||
final detailsA = a['userMusicDetailList'] as List? ?? [];
|
||
final detailsB = b['userMusicDetailList'] as List? ?? [];
|
||
|
||
int maxRatingA = 0;
|
||
for (var d in detailsA) {
|
||
int r = d['rating'] ?? 0;
|
||
if (r > maxRatingA) maxRatingA = r;
|
||
}
|
||
|
||
int maxRatingB = 0;
|
||
for (var d in detailsB) {
|
||
int r = d['rating'] ?? 0;
|
||
if (r > maxRatingB) maxRatingB = r;
|
||
}
|
||
|
||
return maxRatingB.compareTo(maxRatingA);
|
||
});
|
||
|
||
return result;
|
||
}
|
||
|
||
void _resetFilters() {
|
||
setState(() {
|
||
_searchQuery = '';
|
||
_filterLevelType = null;
|
||
_filterRank = null;
|
||
_filterFromVersions.clear();
|
||
_filterGenres.clear();
|
||
_selectedMinLevel = null;
|
||
_selectedMaxLevel = null;
|
||
_minAchievement = null;
|
||
_filterComboStatus = null;
|
||
_filterSyncStatus = null;
|
||
});
|
||
_minAchievementController.clear();
|
||
_minLevelScrollController.jumpToItem(0);
|
||
_maxLevelScrollController.jumpToItem(_levelOptions.length - 1);
|
||
}
|
||
|
||
@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: () => _loadData(isInitialLoad: false))
|
||
],
|
||
),
|
||
body: Stack(
|
||
children: [
|
||
LiquidGlowBackground(
|
||
blurSigma: 60, // 模糊程度
|
||
duration: const Duration(seconds: 10), // 动画周期
|
||
blobs: [
|
||
GlowBlobConfig(
|
||
color: Colors.blueAccent.withOpacity(0.5),
|
||
size: 150,
|
||
begin: const Alignment(-0.8, -0.4),
|
||
end: const Alignment(-0.2, 0.2), // 移动到中央偏左
|
||
),
|
||
GlowBlobConfig(
|
||
color: Colors.pinkAccent.withOpacity(0.4),
|
||
size: 100,
|
||
begin: const Alignment(-0.8, 0.9),
|
||
end: const Alignment(0.3, 0.5),
|
||
),
|
||
GlowBlobConfig(
|
||
color: Colors.white.withOpacity(0.3),
|
||
size: 200,
|
||
begin: const Alignment(2.0, -1.4), // 从正下方溢出处
|
||
end: const Alignment(0.0, -0.5), // 向上浮动
|
||
),
|
||
],
|
||
),
|
||
// 2. 主内容层
|
||
SafeArea(
|
||
bottom: false,
|
||
child: Column(
|
||
children: [
|
||
_buildDataSourceSelector(),
|
||
_buildFilterBar(),
|
||
Expanded(
|
||
child: Container(
|
||
child: _buildBody(),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildDataSourceSelector() {
|
||
final userProvider = UserProvider.instance;
|
||
final isSegaMode = userProvider.scoreDataSource == 'sega';
|
||
|
||
final cnUserNames = userProvider.availableCnUserNames;
|
||
final currentCnUserName = userProvider.selectedCnUserName;
|
||
|
||
final segaCards = userProvider.availableSegaCards;
|
||
final currentSegaId = userProvider.selectedSegaId;
|
||
|
||
// 安全处理:确保 value 一定存在于 items 中
|
||
String? safeValue;
|
||
if (isSegaMode) {
|
||
// 检查 segaId 是否真的在列表里
|
||
safeValue = segaCards.any((card) => card.segaId == currentSegaId) ? currentSegaId : null;
|
||
} else {
|
||
// 检查用户名是否真的在列表里
|
||
safeValue = (currentCnUserName != null && cnUserNames.contains(currentCnUserName)) ? currentCnUserName : null;
|
||
}
|
||
|
||
return Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||
color: Theme.of(context).cardColor,
|
||
child: Row(
|
||
children: [
|
||
const Text("数据源:", style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)),
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
child: ToggleButtons(
|
||
isSelected: [!isSegaMode, isSegaMode],
|
||
onPressed: (index) async {
|
||
final newSource = index == 0 ? 'cn' : 'sega';
|
||
await userProvider.setScoreDataSource(newSource);
|
||
},
|
||
borderRadius: BorderRadius.circular(8),
|
||
selectedBorderColor: Theme.of(context).primaryColor,
|
||
selectedColor: Colors.white,
|
||
fillColor: Colors.deepPurple,
|
||
color: Colors.grey,
|
||
constraints: const BoxConstraints(minHeight: 30, minWidth: 70),
|
||
children: const [
|
||
Text("国服"),
|
||
Text("INTL/JP"),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
flex: 1,
|
||
child: DropdownButtonHideUnderline(
|
||
child: DropdownButtonFormField<String>(
|
||
value: safeValue, // <--- 这里用安全值,修复报错
|
||
isExpanded: true,
|
||
decoration: InputDecoration(
|
||
hintText: isSegaMode ? "选择卡片" : "选择账号",
|
||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||
contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 0),
|
||
isDense: false,
|
||
),
|
||
items: isSegaMode
|
||
? segaCards.map((card) {
|
||
return DropdownMenuItem<String>(
|
||
value: card.segaId,
|
||
child: Text(card.segaId ?? "Unknown ID", overflow: TextOverflow.ellipsis),
|
||
);
|
||
}).toList()
|
||
: [
|
||
const DropdownMenuItem<String>(
|
||
value: null,
|
||
child: Text("全部/默认", overflow: TextOverflow.ellipsis),
|
||
),
|
||
...cnUserNames.map((name) {
|
||
return DropdownMenuItem<String>(
|
||
value: name,
|
||
child: Text(name, overflow: TextOverflow.ellipsis),
|
||
);
|
||
}).toList()
|
||
],
|
||
onChanged: (val) async {
|
||
if (isSegaMode) {
|
||
await userProvider.setSelectedSegaId(val);
|
||
} else {
|
||
await userProvider.setSelectedCnUserName(val);
|
||
}
|
||
},
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
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: (val) { setState(() { _searchQuery = val; }); },
|
||
),
|
||
),
|
||
IconButton(
|
||
icon: Icon(_isAdvancedFilterExpanded ? Icons.expand_less : Icons.expand_more),
|
||
onPressed: () {
|
||
setState(() {
|
||
_isAdvancedFilterExpanded = !_isAdvancedFilterExpanded;
|
||
if (_isAdvancedFilterExpanded) {
|
||
_animationController.forward();
|
||
} else {
|
||
_animationController.reverse();
|
||
}
|
||
});
|
||
},
|
||
),
|
||
if (_hasActiveFilters())
|
||
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: (val) => setState(() => _filterLevelType = val),
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
child: _buildDropdown(
|
||
label: "评级",
|
||
value: _filterRank,
|
||
items: const [
|
||
DropdownMenuItem(value: null, child: Text("全部")),
|
||
DropdownMenuItem(value: 13, child: Text("SSS+")),
|
||
DropdownMenuItem(value: 12, child: Text("SSS")),
|
||
DropdownMenuItem(value: 10, child: Text("SS")),
|
||
DropdownMenuItem(value: 8, child: Text("S")),
|
||
DropdownMenuItem(value: 5, child: Text("A")),
|
||
],
|
||
onChanged: (val) => setState(() => _filterRank = val),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
SizeTransition(
|
||
sizeFactor: _animation,
|
||
axisAlignment: -1.0,
|
||
child: Container(
|
||
padding: const EdgeInsets.all(12),
|
||
decoration: BoxDecoration(
|
||
color: Colors.grey.withAlpha(20),
|
||
border: Border(top: BorderSide(color: Colors.grey.shade300)),
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
// 版本多选按钮
|
||
Expanded(
|
||
child: _buildMultiSelectButton(
|
||
title: "版本",
|
||
selectedList: _filterFromVersions,
|
||
allItems: _availableVersions.toList(),
|
||
onConfirm: (selected) {
|
||
setState(() {
|
||
_filterFromVersions = selected;
|
||
});
|
||
},
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
// 流派多选按钮
|
||
Expanded(
|
||
child: _buildMultiSelectButton(
|
||
title: "流派",
|
||
selectedList: _filterGenres,
|
||
allItems: _availableGenres.toList(),
|
||
onConfirm: (selected) {
|
||
setState(() {
|
||
_filterGenres = selected;
|
||
});
|
||
},
|
||
),
|
||
),
|
||
],
|
||
),
|
||
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: [
|
||
Text("定数范围", style: TextStyle(fontWeight: FontWeight.bold)),
|
||
Text(_getLevelRangeText(), style: TextStyle(color: Colors.grey[600], fontSize: 14)),
|
||
const Icon(Icons.arrow_drop_down, color: Colors.grey),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
TextField(
|
||
controller: _minAchievementController,
|
||
decoration: const InputDecoration(
|
||
labelText: "最小达成率 (%)",
|
||
isDense: true,
|
||
contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||
),
|
||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||
onChanged: (val) {
|
||
setState(() {
|
||
if (val.isEmpty) {
|
||
_minAchievement = null;
|
||
} else {
|
||
_minAchievement = double.tryParse(val);
|
||
}
|
||
});
|
||
},
|
||
),
|
||
const SizedBox(height: 8),
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: _buildDropdown(
|
||
label: "Combo",
|
||
value: _filterComboStatus,
|
||
items: const [
|
||
DropdownMenuItem(value: null, child: Text("全部")),
|
||
DropdownMenuItem(value: 0, child: Text("无")),
|
||
DropdownMenuItem(value: 1, child: Text("FC")),
|
||
DropdownMenuItem(value: 2, child: Text("FC+")),
|
||
DropdownMenuItem(value: 3, child: Text("AP")),
|
||
DropdownMenuItem(value: 4, child: Text("AP+")),
|
||
],
|
||
onChanged: (val) => setState(() => _filterComboStatus = val),
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
child: _buildDropdown(
|
||
label: "Sync",
|
||
value: _filterSyncStatus,
|
||
items: const [
|
||
DropdownMenuItem(value: null, child: Text("全部")),
|
||
DropdownMenuItem(value: 0, child: Text("无")),
|
||
DropdownMenuItem(value: 1, child: Text("FS")),
|
||
DropdownMenuItem(value: 2, child: Text("FS+")),
|
||
DropdownMenuItem(value: 3, child: Text("FDX")),
|
||
DropdownMenuItem(value: 4, child: Text("FDX+")),
|
||
DropdownMenuItem(value: 5, child: Text("Sync")),
|
||
],
|
||
onChanged: (val) => setState(() => _filterSyncStatus = val),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
// 多选弹窗组件
|
||
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([]);
|
||
Navigator.pop(dialogContext);
|
||
},
|
||
child: const Text("重置", style: TextStyle(color: Colors.red)),
|
||
),
|
||
TextButton(
|
||
onPressed: () {
|
||
onConfirm(tempSelected);
|
||
Navigator.pop(dialogContext);
|
||
},
|
||
child: const Text("确定"),
|
||
),
|
||
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
void _showLevelPickerDialog() {
|
||
int targetMinIndex = 0;
|
||
if (_selectedMinLevel != null) {
|
||
int idx = _levelOptions.indexOf(_selectedMinLevel!);
|
||
if (idx != -1) targetMinIndex = idx;
|
||
}
|
||
int targetMaxIndex = _levelOptions.length - 1;
|
||
if (_selectedMaxLevel != null) {
|
||
int idx = _levelOptions.indexOf(_selectedMaxLevel!);
|
||
if (idx != -1) targetMaxIndex = idx;
|
||
}
|
||
|
||
showModalBottomSheet(
|
||
context: context,
|
||
shape: const RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||
),
|
||
builder: (context) {
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
_minLevelScrollController.jumpToItem(targetMinIndex);
|
||
_maxLevelScrollController.jumpToItem(targetMaxIndex);
|
||
});
|
||
|
||
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 minIndex = _minLevelScrollController.selectedItem;
|
||
final maxIndex = _maxLevelScrollController.selectedItem;
|
||
double minVal = _levelOptions[minIndex];
|
||
double maxVal = _levelOptions[maxIndex];
|
||
if (minVal > maxVal) {
|
||
final temp = minVal;
|
||
minVal = maxVal;
|
||
maxVal = temp;
|
||
}
|
||
setState(() {
|
||
_selectedMinLevel = minVal;
|
||
_selectedMaxLevel = maxVal;
|
||
});
|
||
Navigator.pop(context);
|
||
},
|
||
child: const Text("确定", style: TextStyle(fontWeight: FontWeight.bold)),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const Divider(),
|
||
Expanded(
|
||
child: Row(
|
||
children: [
|
||
Expanded(
|
||
child: Column(
|
||
children: [
|
||
const Padding(padding: EdgeInsets.only(bottom: 8), child: Text("最小定数", style: TextStyle(fontSize: 12, color: Colors.grey))),
|
||
Expanded(
|
||
child: CupertinoPicker(
|
||
scrollController: _minLevelScrollController,
|
||
itemExtent: 40,
|
||
onSelectedItemChanged: (index) {},
|
||
children: _levelOptions.map((level) {
|
||
return Center(
|
||
child: Text(
|
||
level.toStringAsFixed(1),
|
||
style: const TextStyle(fontSize: 18,color: Colors.redAccent),
|
||
),
|
||
);
|
||
}).toList(),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
Expanded(
|
||
child: Column(
|
||
children: [
|
||
const Padding(padding: EdgeInsets.only(bottom: 8), child: Text("最大定数", style: TextStyle(fontSize: 12, color: Colors.grey))),
|
||
Expanded(
|
||
child: CupertinoPicker(
|
||
scrollController: _maxLevelScrollController,
|
||
itemExtent: 40,
|
||
onSelectedItemChanged: (index) {},
|
||
children: _levelOptions.map((level) {
|
||
return Center(
|
||
child: Text(
|
||
level.toStringAsFixed(1),
|
||
style: const TextStyle(fontSize: 18,color: Colors.green),
|
||
),
|
||
);
|
||
}).toList(),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
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";
|
||
}
|
||
|
||
Widget _buildDropdown({
|
||
required String label,
|
||
required dynamic value,
|
||
required List<DropdownMenuItem<dynamic>> items,
|
||
required ValueChanged<dynamic?> onChanged,
|
||
}) {
|
||
return DropdownButtonFormField<dynamic>(
|
||
value: value,
|
||
isExpanded: true,
|
||
decoration: InputDecoration(
|
||
labelText: label,
|
||
border: const OutlineInputBorder(),
|
||
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
||
isDense: false,
|
||
),
|
||
style: Theme.of(context).textTheme.bodyMedium,
|
||
items: items.map((item) {
|
||
String textContent = '';
|
||
if (item.child is Text) {
|
||
textContent = (item.child as Text).data ?? '';
|
||
} else if (item.child is String) {
|
||
textContent = item.child as String;
|
||
}
|
||
return DropdownMenuItem<dynamic>(
|
||
value: item.value,
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(vertical: 4.0),
|
||
child: Text(
|
||
textContent,
|
||
softWrap: true,
|
||
overflow: TextOverflow.visible,
|
||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(height: 1.2),
|
||
),
|
||
),
|
||
);
|
||
}).toList(),
|
||
onChanged: onChanged,
|
||
);
|
||
}
|
||
|
||
bool _hasActiveFilters() {
|
||
return _searchQuery.isNotEmpty ||
|
||
_filterLevelType != null ||
|
||
_filterRank != null ||
|
||
_filterFromVersions.isNotEmpty ||
|
||
_filterGenres.isNotEmpty ||
|
||
_selectedMinLevel != null ||
|
||
_selectedMaxLevel != null ||
|
||
_minAchievement != null ||
|
||
_filterComboStatus != null ||
|
||
_filterSyncStatus != null;
|
||
}
|
||
|
||
Widget _buildBody() {
|
||
if (_isLoading && _userMusicList.isEmpty) {
|
||
if (_allSongs.isEmpty) {
|
||
return const Center(child: CircularProgressIndicator());
|
||
}
|
||
return const Center(child: CircularProgressIndicator());
|
||
}
|
||
|
||
if (_errorMessage.isNotEmpty && _userMusicList.isEmpty) {
|
||
return Center(
|
||
child: Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
Text(_errorMessage, style: const TextStyle(color: Colors.red)),
|
||
const SizedBox(height: 20),
|
||
ElevatedButton(onPressed: () => _loadData(isInitialLoad: true), child: const Text("重试"))
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
final filteredList = _filteredMusicList;
|
||
|
||
if (filteredList.isEmpty) {
|
||
return const Center(child: Text("没有找到匹配的成绩"));
|
||
}
|
||
|
||
final stats = _filterStats;
|
||
final songCount = stats['songCount'] as int;
|
||
final scoreCount = stats['scoreCount'] as int;
|
||
final maxRating = stats['maxRating'] as int;
|
||
|
||
return Column(
|
||
children: [
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||
decoration: BoxDecoration(
|
||
color: Theme.of(context).cardColor,
|
||
borderRadius: BorderRadius.circular(8),
|
||
),
|
||
child: Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||
children: [
|
||
Column(
|
||
children: [
|
||
const Text("歌曲", style: TextStyle(color: Colors.grey, fontSize: 12)),
|
||
const SizedBox(height: 2),
|
||
Text("$songCount", style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
|
||
],
|
||
),
|
||
Column(
|
||
children: [
|
||
const Text("成绩", style: TextStyle(color: Colors.grey, fontSize: 12)),
|
||
const SizedBox(height: 2),
|
||
Text("$scoreCount", style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
|
||
],
|
||
),
|
||
Column(
|
||
children: [
|
||
const Text("最高Rating", style: TextStyle(color: Colors.grey, fontSize: 12)),
|
||
const SizedBox(height: 2),
|
||
Text("$maxRating", style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
Expanded(
|
||
child: ListView.builder(
|
||
itemCount: filteredList.length,
|
||
cacheExtent: 500,
|
||
itemBuilder: (context, index) {
|
||
final musicGroup = filteredList[index];
|
||
final details = musicGroup['userMusicDetailList'] as List? ?? [];
|
||
if (details.isEmpty) return const SizedBox.shrink();
|
||
|
||
final firstDetail = details[0];
|
||
final int musicId = firstDetail['musicId'];
|
||
|
||
final info = _getSongDisplayInfo(musicId);
|
||
final SongModel? songInfo = info['song'] as SongModel?;
|
||
|
||
String rawTitle = info['title'] as String;
|
||
if (songInfo == null && firstDetail.containsKey('musicName')) {
|
||
rawTitle = firstDetail['musicName'] ?? rawTitle;
|
||
}
|
||
|
||
final String displayTitle = "[$musicId] $rawTitle";
|
||
final String artist = songInfo?.artist ?? (firstDetail['artist'] ?? 'Unknown Artist');
|
||
final String coverUrl = _getCoverUrl(musicId);
|
||
|
||
return Card(
|
||
margin: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||
clipBehavior: Clip.antiAlias,
|
||
elevation: 6,
|
||
shadowColor: Colors.purpleAccent.withOpacity(0.3),
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(12),
|
||
),
|
||
child: ExpansionTile(
|
||
tilePadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||
leading: GestureDetector(
|
||
onTap: () {
|
||
_shareImageViaSystem(coverUrl, rawTitle);
|
||
},
|
||
child: ClipRRect(
|
||
borderRadius: BorderRadius.circular(4),
|
||
child: Stack(
|
||
children: [
|
||
CacheImage.network(
|
||
coverUrl,
|
||
width: 50,
|
||
height: 50,
|
||
fit: BoxFit.cover,
|
||
loadingBuilder: (context, child, loadingProgress) {
|
||
if (loadingProgress == null) return child;
|
||
return Container(
|
||
width: 50,
|
||
height: 50,
|
||
color: Colors.grey[200],
|
||
child: const Center(
|
||
child: SizedBox(
|
||
width: 15,
|
||
height: 15,
|
||
child: CircularProgressIndicator(
|
||
strokeWidth: 2))),
|
||
);
|
||
},
|
||
errorBuilder: (context, error, stackTrace) {
|
||
return Container(
|
||
width: 50,
|
||
height: 50,
|
||
color: Colors.grey[300],
|
||
child: const Icon(Icons.music_note,
|
||
color: Colors.grey),
|
||
);
|
||
},
|
||
),
|
||
Positioned(
|
||
right: 0,
|
||
bottom: 0,
|
||
child: Container(
|
||
padding: const EdgeInsets.all(2),
|
||
decoration: BoxDecoration(
|
||
color: Colors.black.withOpacity(0.5),
|
||
borderRadius: const BorderRadius.only(
|
||
topLeft: Radius.circular(4),
|
||
),
|
||
),
|
||
child: const Icon(
|
||
Icons.share,
|
||
color: Colors.white,
|
||
size: 10,
|
||
),
|
||
),
|
||
)
|
||
],
|
||
),
|
||
),
|
||
),
|
||
title: Text(
|
||
displayTitle,
|
||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
subtitle: Text(
|
||
artist,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: TextStyle(color: songInfo == null ? Colors.orange : null),
|
||
),
|
||
children: details.map((detail) {
|
||
int comboStatus = detail['comboStatus'] ?? 0;
|
||
String comboText = "";
|
||
Color comboColor = Colors.grey;
|
||
IconData? comboIcon;
|
||
|
||
if (comboStatus == 1) {
|
||
comboText = "FC";
|
||
comboColor = Colors.green;
|
||
comboIcon = Icons.star;
|
||
} else if (comboStatus == 2) {
|
||
comboText = "FC+";
|
||
comboColor = Colors.green;
|
||
comboIcon = Icons.diamond;
|
||
} else if (comboStatus == 3) {
|
||
comboText = "AP";
|
||
comboColor = Colors.deepOrange;
|
||
comboIcon = Icons.diamond;
|
||
}else if (comboStatus == 4) {
|
||
comboText = "AP+";
|
||
comboColor = Colors.purpleAccent;
|
||
comboIcon = Icons.diamond;
|
||
}
|
||
|
||
int syncStatus = detail['syncStatus'] ?? 0;
|
||
String syncText = "";
|
||
Color syncColor = Colors.grey;
|
||
IconData? syncIcon;
|
||
|
||
if (syncStatus == 1) {
|
||
syncText = "FS";
|
||
syncColor = Colors.lightBlueAccent;
|
||
syncIcon = Icons.check_circle_outline;
|
||
} else if (syncStatus == 2) {
|
||
syncText = "FS+";
|
||
syncColor = Colors.lightBlueAccent;
|
||
syncIcon = Icons.check_circle;
|
||
} else if (syncStatus == 3) {
|
||
syncText = "FDX";
|
||
syncColor = Colors.orangeAccent;
|
||
syncIcon = Icons.check_circle;
|
||
} else if (syncStatus == 4) {
|
||
syncText = "FDX+";
|
||
syncColor = Colors.orangeAccent;
|
||
syncIcon = Icons.auto_awesome;
|
||
} else if (syncStatus == 5) {
|
||
syncText = "Sync";
|
||
syncColor = Colors.blueAccent;
|
||
syncIcon = Icons.auto_awesome;
|
||
}
|
||
|
||
return ListTile(
|
||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||
title: Row(
|
||
children: [
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||
decoration: BoxDecoration(
|
||
color: Colors.white70,
|
||
borderRadius: BorderRadius.circular(4),
|
||
),
|
||
child: Text(
|
||
_getLevelName(detail['level']),
|
||
style: TextStyle(
|
||
color: _getLevelColor(detail['level']),
|
||
fontWeight: FontWeight.bold,
|
||
fontSize: 12
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
Text(
|
||
"Lv.${detail['level_info']}",
|
||
style: const TextStyle(fontSize: 14, color: Colors.grey, fontWeight: FontWeight.w500),
|
||
),
|
||
],
|
||
),
|
||
subtitle: _buildScoreInfo(detail,songInfo!),
|
||
trailing: Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
crossAxisAlignment: CrossAxisAlignment.end,
|
||
children: [
|
||
Text(
|
||
"${(detail['achievement'] / 10000).toStringAsFixed(4)}%",
|
||
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.bold),
|
||
),
|
||
const SizedBox(height: 4),
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||
decoration: BoxDecoration(
|
||
color: _getRankColor(detail['scoreRank']),
|
||
borderRadius: BorderRadius.circular(4),
|
||
),
|
||
child: Text(
|
||
_getRankText(detail['scoreRank']),
|
||
style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold),
|
||
),
|
||
)
|
||
],
|
||
),
|
||
);
|
||
}).toList(),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
String _getLevelName(dynamic level) {
|
||
switch (level) {
|
||
case 0: return "BAS";
|
||
case 1: return "ADV";
|
||
case 2: return "EXP";
|
||
case 3: return "MAS";
|
||
case 4: return "ReM";
|
||
default: return "Lv.$level";
|
||
}
|
||
}
|
||
|
||
Color _getLevelColor(dynamic level) {
|
||
if (level is int) {
|
||
switch (level) {
|
||
case 0: return Colors.green;
|
||
case 1: return Colors.yellow[700]!;
|
||
case 2: return Colors.red;
|
||
case 3: return Colors.purple;
|
||
case 4: return Colors.purpleAccent.withOpacity(0.4);
|
||
default: return Colors.black;
|
||
}
|
||
}
|
||
return Colors.black;
|
||
}
|
||
|
||
Widget _buildScoreInfo(Map<String, dynamic> score, SongModel song) {
|
||
final dxScore = score['deluxscoreMax'] ?? 0;
|
||
final playCount = score['playCount'] ?? 0;
|
||
final type = score['type'] ?? '';
|
||
final level = score['level'] ?? 0;
|
||
final rating = score['rating'] ?? 0;
|
||
|
||
int comboStatus = score['comboStatus'] ?? 0;
|
||
int syncStatus = score['syncStatus'] ?? 0;
|
||
|
||
int totalNotes = 0;
|
||
if (song.dx != null || song.sd != null) {
|
||
final levelKey = level.toString();
|
||
if (score['musicId'] > 10000) {
|
||
totalNotes = song.dx![levelKey]['notes']['total'] ?? 0;
|
||
} else {
|
||
totalNotes = song.sd![levelKey]['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),
|
||
// 🔴 核心修复:用 Wrap 自动换行,代替会溢出的 Row
|
||
child: Wrap(
|
||
spacing: 10, // 元素横向间距
|
||
runSpacing: 6, // 换行后纵向间距
|
||
crossAxisAlignment: WrapCrossAlignment.center,
|
||
alignment: WrapAlignment.spaceBetween,
|
||
children: [
|
||
// 左侧信息组
|
||
Wrap(
|
||
spacing: 8,
|
||
crossAxisAlignment: WrapCrossAlignment.center,
|
||
children: [
|
||
const Icon(Icons.score_rounded, size: 14, color: Colors.blueGrey),
|
||
Text(
|
||
"DX: $dxScore / $allDx",
|
||
style: const TextStyle(fontSize: 13, color: Colors.blueGrey, fontWeight: FontWeight.w500),
|
||
),
|
||
|
||
const Icon(Icons.play_arrow_rounded, size: 14, color: Colors.grey),
|
||
Text(
|
||
"$playCount 次",
|
||
style: const TextStyle(fontSize: 13, color: Colors.grey),
|
||
),
|
||
|
||
const Icon(Icons.star_rate_rounded, size: 14, color: Colors.amber),
|
||
Text(
|
||
"Rating: $rating",
|
||
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500),
|
||
),
|
||
|
||
// 图标
|
||
if (comboIconPath != null)
|
||
Image.asset(comboIconPath, width: 26, height: 26),
|
||
if (syncIconPath != null)
|
||
Image.asset(syncIconPath, width: 26, height: 26),
|
||
],
|
||
),
|
||
|
||
// 右侧星级(自动换到下一行)
|
||
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;
|
||
}
|
||
|
||
String _getRankText(int rank) {
|
||
switch (rank) {
|
||
case 13: return "SSS+";
|
||
case 12: return "SSS";
|
||
case 11: return "SS+";
|
||
case 10: return "SS";
|
||
case 9: return "S+";
|
||
case 8: return "S";
|
||
case 7: return "AAA";
|
||
case 6: return "AA";
|
||
case 5: return "A";
|
||
case 4: return "BBB";
|
||
case 3: return "BB";
|
||
case 2: return "B";
|
||
case 1: return "C";
|
||
default: return "D";
|
||
}
|
||
}
|
||
|
||
Map<String, dynamic> get _filterStats {
|
||
final list = _filteredMusicList;
|
||
int songCount = list.length;
|
||
int scoreCount = 0;
|
||
int maxRating = 0;
|
||
|
||
for (var group in list) {
|
||
final details = group['userMusicDetailList'] as List? ?? [];
|
||
scoreCount += details.length;
|
||
for (var d in details) {
|
||
int r = d['rating'] ?? 0;
|
||
if (r > maxRating) maxRating = r;
|
||
}
|
||
}
|
||
|
||
return {
|
||
'songCount': songCount,
|
||
'scoreCount': scoreCount,
|
||
'maxRating': maxRating,
|
||
};
|
||
}
|
||
|
||
Future<void> _shareImageViaSystem(String imageUrl, String title) async {
|
||
if (imageUrl.isEmpty) return;
|
||
try {
|
||
final dio = Dio();
|
||
final response = await dio.get<List<int>>(
|
||
imageUrl,
|
||
options: Options(responseType: ResponseType.bytes),
|
||
);
|
||
if (response.data == null || response.data!.isEmpty) {
|
||
throw Exception("图片数据为空");
|
||
}
|
||
final tempDir = await getTemporaryDirectory();
|
||
final ext = imageUrl.endsWith('.jpg') || imageUrl.endsWith('.jpeg') ? 'jpg' : 'png';
|
||
final fileName = 'shared_img_${DateTime.now().millisecondsSinceEpoch}.$ext';
|
||
final file = File('${tempDir.path}/$fileName');
|
||
await file.writeAsBytes(response.data!);
|
||
await Share.shareXFiles(
|
||
[XFile(file.path)],
|
||
text: "推荐歌曲:$title",
|
||
);
|
||
} catch (e) {
|
||
debugPrint("分享失败: $e");
|
||
if (mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(content: Text("分享失败: $e")),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
} |