Files
UnionApp/lib/pages/score/score_page.dart
spasolreisa 5085cedaeb ver1.00.00
update3
2026-04-23 00:08:01 +08:00

1563 lines
57 KiB
Dart
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 '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")),
);
}
}
}
}