1351 lines
52 KiB
Dart
1351 lines
52 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;
|
||
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")),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
} |