Files
UnionApp/lib/pages/score/score_page.dart
spasolreisa 9ce601aa8d initial
2026-04-16 14:26:52 +08:00

1351 lines
52 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

import 'dart:io';
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';
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;
String? _filterFromVersion;
String? _filterGenre;
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;
final token = userProvider.token;
if (token == null) throw "未登录,请先登录";
// 1. 获取歌曲元数据
if (_allSongs.isEmpty || isInitialLoad) {
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();
}
// 2. 获取用户成绩数据
if (_currentDataSource == 'sega') {
// --- Sega ID 模式 ---
final segaId = _currentSegaId;
if (segaId == null || segaId.isEmpty) {
throw "请选择一个有效的 Sega ID";
}
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 {
// --- 国服模式 ---
// 【修改点】传递选中的用户名
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 = ''; });
} 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 &&
_filterFromVersion == null &&
_filterGenre == null &&
_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 (_filterFromVersion != null && song.from != _filterFromVersion) return false;
if (_filterGenre != null && song.genre != _filterGenre) return false;
} else {
if (_filterFromVersion != null || _filterGenre != null) {
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;
_filterFromVersion = null;
_filterGenre = null;
_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: 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;
// Sega相关数据
final segaCards = userProvider.availableSegaCards;
final currentSegaId = userProvider.selectedSegaId;
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>(
// 如果是 Sega 模式,显示 SegaID如果是国服模式显示用户名
value: isSegaMode
? currentSegaId
: (currentCnUserName != null && currentCnUserName.isNotEmpty ? currentCnUserName : null), 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: _buildDropdown(
label: "版本",
value: _filterFromVersion,
items: [
const DropdownMenuItem(value: null, child: Text("全部")),
..._availableVersions.map((v) => DropdownMenuItem(value: v, child: Text(v))).toList()
],
onChanged: (val) => setState(() => _filterFromVersion = val),
),
),
const SizedBox(width: 8),
Expanded(
child: _buildDropdown(
label: "流派",
value: _filterGenre,
items: [
const DropdownMenuItem(value: null, child: Text("全部")),
..._availableGenres.map((g) => DropdownMenuItem(value: g, child: Text(g))).toList()
],
onChanged: (val) => setState(() => _filterGenre = val),
),
),
],
),
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),
),
),
],
),
],
),
),
),
],
),
);
}
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 ||
_filterFromVersion != null ||
_filterGenre != null ||
_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: [
Image.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: Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.play_circle_outline, size: 16, color: Colors.grey[600]),
const SizedBox(width: 4),
Text(
"Play: ${detail['playCount'] ?? 0}",
style: TextStyle(fontSize: 12, color: Colors.grey[700]),
),
const SizedBox(width: 12),
Icon(Icons.score, size: 16, color: Colors.grey[600]),
const SizedBox(width: 4),
Text(
"DX: ${detail['deluxscoreMax'] ?? 0}",
style: TextStyle(fontSize: 12, color: Colors.grey[700]),
),
const SizedBox(width: 12),
Icon(Icons.attach_file, size: 16, color: Colors.grey[600]),
const SizedBox(width: 4),
Text(
"Rating: ${detail['rating'] ?? 0}",
style: TextStyle(fontSize: 12, color: Colors.grey[700]),
),
],
),
const SizedBox(height: 4),
Row(
children: [
if (comboStatus > 0)
Container(
margin: const EdgeInsets.only(right: 8),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
border: Border.all(color: comboColor.withAlpha(100)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (comboIcon != null) Icon(comboIcon, size: 12, color: comboColor),
if (comboIcon != null) const SizedBox(width: 2),
Text(comboText, style: TextStyle(fontSize: 14, color: comboColor, fontWeight: FontWeight.bold)),
],
),
),
if (syncStatus > 0)
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
border: Border.all(color: syncColor.withAlpha(100)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (syncIcon != null) Icon(syncIcon, size: 12, color: syncColor),
if (syncIcon != null) const SizedBox(width: 2),
Text(syncText, style: TextStyle(fontSize: 14, color: syncColor, fontWeight: FontWeight.bold)),
],
),
),
],
),
],
),
),
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;
}
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")),
);
}
}
}
}