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

1501 lines
54 KiB
Dart

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;
// 改为多选
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;
final token = userProvider.token;
if (token == null) throw "未登录,请先登录";
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();
}
if (_currentDataSource == 'sega') {
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 &&
_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: 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;
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: 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: _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(tempSelected);
Navigator.pop(dialogContext);
},
child: const Text("确定"),
),
TextButton(
onPressed: () {
onConfirm([]);
Navigator.pop(dialogContext);
},
child: const Text("重置", style: TextStyle(color: Colors.red)),
),
],
),
);
}
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: [
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: _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 ((type == 'dx' && song.dx != null) || (type == 'sd' && song.sd != null)) {
final levelKey = level.toString();
final levelData = type == 'dx' ? song.dx![levelKey] : song.sd![levelKey];
if (levelData != null && levelData['notes'] != null) {
totalNotes = levelData['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),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Flexible(
child: Row(
children: [
const Icon(Icons.score_rounded, size: 14, color: Colors.blueGrey),
const SizedBox(width: 4),
Text(
"DX: $dxScore / $allDx",
style: const TextStyle(fontSize: 13, color: Colors.blueGrey, fontWeight: FontWeight.w500),
),
const SizedBox(width: 10),
const Icon(Icons.play_arrow_rounded, size: 14, color: Colors.grey),
const SizedBox(width: 4),
Text(
"$playCount",
style: const TextStyle(fontSize: 13, color: Colors.grey),
),
const SizedBox(width: 10),
const Icon(Icons.star_rate_rounded, size: 14, color: Colors.amber),
const SizedBox(width: 4),
Text(
"Rating: $rating",
style: const TextStyle(fontSize: 13,fontWeight: FontWeight.w500),
),
const SizedBox(width: 8),
if (comboIconPath != null)
Padding(
padding: const EdgeInsets.only(left: 4),
child: Image.asset(comboIconPath, width: 30, height: 30),
),
if (syncIconPath != null)
Padding(
padding: const EdgeInsets.only(left: 4),
child: Image.asset(syncIconPath, width: 30, height: 30),
),
],
),
),
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")),
);
}
}
}
}