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