From 00bd43dc7f5da557bc62352881919b79186be0b2 Mon Sep 17 00:00:00 2001 From: spasolreisa Date: Sat, 18 Apr 2026 02:11:45 +0800 Subject: [PATCH] =?UTF-8?q?0418=200222=20=E6=9B=B4=E6=96=B02?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/model/song_model.dart | 124 +++- lib/pages/home/home_page.dart | 16 +- lib/pages/music/music_page.dart | 919 ++++++++++++++++++++++++++ lib/pages/music/score_single.dart | 569 ++++++++++++++++ lib/pages/songlistpage.dart | 15 - lib/service/user_service.dart | 30 + lib/widgets/score_progress_chart.dart | 156 +++++ 7 files changed, 1805 insertions(+), 24 deletions(-) create mode 100644 lib/pages/music/music_page.dart create mode 100644 lib/pages/music/score_single.dart delete mode 100644 lib/pages/songlistpage.dart create mode 100644 lib/widgets/score_progress_chart.dart diff --git a/lib/model/song_model.dart b/lib/model/song_model.dart index 7ed810a..fb2ccec 100644 --- a/lib/model/song_model.dart +++ b/lib/model/song_model.dart @@ -1,5 +1,6 @@ class SongModel { final int id; + final int players; final String title; final String artist; final Map? utTitle; // 可选,根据不同版本标题可能不同 @@ -12,6 +13,12 @@ class SongModel { final String releaseDate; final String from; + // 新增:四个标签字段 + final bool cn; + final bool jp; + final bool m2l; + final bool long; + // 难度详情映射 key通常是 "0"(Basic), "1"(Advanced) 等,或者 ut 的特殊id final Map? dx; final Map? sd; @@ -19,6 +26,7 @@ class SongModel { SongModel({ required this.id, + required this.players, required this.title, required this.artist, this.utTitle, @@ -30,6 +38,11 @@ class SongModel { required this.bpm, required this.releaseDate, required this.from, + // 新增 + required this.cn, + required this.jp, + required this.m2l, + required this.long, this.dx, this.sd, this.ut, @@ -38,6 +51,7 @@ class SongModel { factory SongModel.fromJson(Map json) { return SongModel( id: json['id'] ?? 0, + players: json['players']??0, title: json['title'] ?? 'Unknown', artist: json['artist'] ?? 'Unknown', utTitle: json['utTitle'], @@ -49,6 +63,11 @@ class SongModel { bpm: json['bpm'] ?? 0, releaseDate: json['releaseDate'] ?? '', from: json['from'] ?? '', + // 新增:JSON 解析,默认 false 更安全 + cn: json['cn'] ?? false, + jp: json['jp'] ?? false, + m2l: json['m2l'] ?? false, + long: json['long'] ?? false, dx: json['dx'], sd: json['sd'], ut: json['ut'], @@ -62,7 +81,11 @@ class SongCacheInfo { final String version; final DateTime lastUpdate; - SongCacheInfo({required this.songSize, required this.version, required this.lastUpdate}); + SongCacheInfo({ + required this.songSize, + required this.version, + required this.lastUpdate, + }); Map toJson() => { 'songSize': songSize, @@ -77,4 +100,103 @@ class SongCacheInfo { lastUpdate: DateTime.fromMillisecondsSinceEpoch(json['lastUpdate'] ?? 0), ); } +} + +// lib/model/chart_log_model.dart + +class ChartLogModel { + final String id; + final dynamic segaChartOld; // 根据示例为 null,类型暂定为 dynamic + final SegaChartNew? segaChartNew; + final String userId; + final int musicId; + final int time; // 时间戳 + + ChartLogModel({ + required this.id, + this.segaChartOld, + this.segaChartNew, + required this.userId, + required this.musicId, + required this.time, + }); + + factory ChartLogModel.fromJson(Map json) { + return ChartLogModel( + id: json['id'] as String? ?? '', + segaChartOld: json['sega_chartOld'], + segaChartNew: json['sega_chartNew'] != null + ? SegaChartNew.fromJson(json['sega_chartNew'] as Map) + : null, + userId: json['userId'] as String? ?? '', + musicId: json['musicId'] as int? ?? 0, + time: json['time'] as int? ?? 0, + ); + } +} + +class SegaChartNew { + final int musicId; + final String? musicName; + final int level; + final double levelInfo; + final int romVersion; + final int achievement; + final int rating; + final String? type; + final int playCount; + final int comboStatus; + final int syncStatus; + final int deluxscoreMax; + final int scoreRank; + final int extNum1; + final int extNum2; + final List alias; + + SegaChartNew({ + required this.musicId, + this.musicName, + required this.level, + required this.levelInfo, + required this.romVersion, + required this.achievement, + required this.rating, + this.type, + required this.playCount, + required this.comboStatus, + required this.syncStatus, + required this.deluxscoreMax, + required this.scoreRank, + required this.extNum1, + required this.extNum2, + required this.alias, + }); + + factory SegaChartNew.fromJson(Map json) { + // 处理 alias 可能存在的非字符串或 null 情况,确保安全性 + var rawAlias = json['alias']; + List aliasList = []; + if (rawAlias is List) { + aliasList = rawAlias.map((e) => e?.toString() ?? '').toList(); + } + + return SegaChartNew( + musicId: json['musicId'] as int? ?? 0, + musicName: json['musicName'] as String?, + level: json['level'] as int? ?? 0, + levelInfo: (json['level_info'] as num?)?.toDouble() ?? 0.0, + romVersion: json['romVersion'] as int? ?? 0, + achievement: json['achievement'] as int? ?? 0, + rating: json['rating'] as int? ?? 0, + type: json['type'] as String?, + playCount: json['playCount'] as int? ?? 0, + comboStatus: json['comboStatus'] as int? ?? 0, + syncStatus: json['syncStatus'] as int? ?? 0, + deluxscoreMax: json['deluxscoreMax'] as int? ?? 0, + scoreRank: json['scoreRank'] as int? ?? 0, + extNum1: json['extNum1'] as int? ?? 0, + extNum2: json['extNum2'] as int? ?? 0, + alias: aliasList, + ); + } } \ No newline at end of file diff --git a/lib/pages/home/home_page.dart b/lib/pages/home/home_page.dart index 1348037..c490e4e 100644 --- a/lib/pages/home/home_page.dart +++ b/lib/pages/home/home_page.dart @@ -1,9 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:unionapp/pages/music/music_page.dart'; import '../../service/recommendation_helper.dart'; import '../../service/song_service.dart'; import '../../tool/gradientText.dart'; import '../user/userpage.dart'; -import '../songlistpage.dart'; import '../scorelist.dart'; import 'package:provider/provider.dart'; import '../../providers/user_provider.dart'; @@ -107,7 +107,7 @@ class HomePage extends StatelessWidget { context: context, title: "歌曲列表", icon: Icons.music_note_outlined, - targetPage: const SongListPage(), + targetPage: const MusicPage(), gradient: const LinearGradient( colors: [Color(0xFFff9a9e), Color(0xFFfecfef)], begin: Alignment.topLeft, @@ -253,7 +253,9 @@ class HomePage extends StatelessWidget { label: "ReiSasol", color: Colors.white, gradient: LinearGradient( - colors: [Colors.orange, Colors.deepOrangeAccent], + colors: [Colors.orangeAccent, Colors.pink], + begin: Alignment.center, + end: Alignment.bottomLeft, ), boxShadow: [ BoxShadow( @@ -473,19 +475,18 @@ class _UserInfoCard extends StatelessWidget { ], ), GradientText( - data:"Ra${userProvider.user?.rating} ", + data:"Ra:${userProvider.user?.rating} Points:${userProvider.user?.points}", style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold), gradientLayers: [ GradientLayer( gradient: const LinearGradient( - colors: [Colors.deepPurple, Colors.pinkAccent], + colors: [Colors.deepPurple, Colors.purple], ), blendMode: BlendMode.srcIn, ), ], ), const Divider(height: 5, thickness: 1), - const SizedBox(height: 6), Text( userProvider.username == "未登录" ? "状态:未登录" @@ -559,10 +560,9 @@ class _RecommendedSongsSection extends StatelessWidget { children: [ const Row( children: [ - Icon(Icons.music_note, color: Colors.purpleAccent), SizedBox(width: 8), Text( - "为你推荐", + "随几首", style: TextStyle( fontSize: 20, fontWeight: FontWeight.bold, diff --git a/lib/pages/music/music_page.dart b/lib/pages/music/music_page.dart new file mode 100644 index 0000000..48fb80c --- /dev/null +++ b/lib/pages/music/music_page.dart @@ -0,0 +1,919 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:unionapp/pages/music/score_single.dart'; +import '../../../model/song_model.dart'; +import '../../../providers/user_provider.dart'; +import '../../../service/song_service.dart'; +import '../../../service/user_service.dart'; + +class MusicPage extends StatefulWidget { + const MusicPage({super.key}); + + @override + State createState() => _MusicPageState(); +} + +class _MusicPageState extends State with SingleTickerProviderStateMixin { + bool _isLoading = true; + bool _isRefreshing = false; + String _errorMessage = ''; + + List _allSongs = []; + List _displaySongs = []; + + // 筛选 + String _searchQuery = ''; + int? _filterLevelType; + String? _filterFromVersion; + String? _filterGenre; + double? _selectedMinLevel; + double? _selectedMaxLevel; + bool _isAdvancedFilterExpanded = false; + + late AnimationController _animationController; + late Animation _animation; + + static const List _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 _availableVersions = {}; + Set _availableGenres = {}; + + // 用户成绩缓存:key = 真实完整 musicId + Map> _userScoreCache = {}; + + @override + void initState() { + super.initState(); + _animationController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 300), + ); + _animation = CurvedAnimation( + parent: _animationController, + curve: Curves.easeInOut, + ); + + _minLevelScrollController = FixedExtentScrollController(initialItem: 0); + _maxLevelScrollController = FixedExtentScrollController(initialItem: _levelOptions.length - 1); + + _loadAllSongs(); + _loadUserScores(); + + UserProvider.instance.addListener(_onUserChanged); + } + + void _onUserChanged() { + if (mounted) { + _loadUserScores(); + } + } + + @override + void dispose() { + UserProvider.instance.removeListener(_onUserChanged); + _animationController.dispose(); + _minLevelScrollController.dispose(); + _maxLevelScrollController.dispose(); + super.dispose(); + } + + // 加载所有歌曲 + Future _loadAllSongs() async { + setState(() => _isLoading = true); + try { + final songs = await SongService.getAllSongs(); + _allSongs = songs; + final uniqueSongs = {}; + for (final song in songs) { + uniqueSongs.putIfAbsent(song.id, () => song); + } + _allSongs = uniqueSongs.values.toList(); + _allSongs.sort((a, b) => (b.players ?? 0).compareTo(a.players ?? 0)); + _displaySongs = List.from(_allSongs); + _availableVersions = songs.map((s) => s.from).where((v) => v.isNotEmpty).toSet(); + _availableGenres = songs.map((s) => s.genre).where((g) => g.isNotEmpty).toSet(); + + setState(() => _errorMessage = ''); + } catch (e) { + setState(() => _errorMessage = e.toString()); + } finally { + setState(() => _isLoading = false); + } + } + + // 加载用户成绩 —— ✅ 修复 1:直接用完整ID存储 + Future _loadUserScores() async { + try { + final userProvider = UserProvider.instance; + final token = userProvider.token; + if (token == null) return; + + final userName = userProvider.selectedCnUserName; + Map scoreData; + + if (userProvider.scoreDataSource == 'sega') { + final segaId = userProvider.selectedSegaId; + if (segaId == null || segaId.isEmpty) return; + scoreData = await UserService.getSegaRatingData(token, segaId); + } else { + scoreData = await SongService.getUserAllScores(token, name: userName); + } + + List userMusicList = []; + + if (scoreData.containsKey('userScoreAll_')) { + userMusicList = scoreData['userScoreAll_']['userMusicList'] ?? []; + } else if (scoreData.containsKey('userMusicList')) { + userMusicList = scoreData['userMusicList'] ?? []; + } else if (scoreData.containsKey('segaId2chartlist')) { + final segaId = userProvider.selectedSegaId; + final map = scoreData['segaId2chartlist'] ?? {}; + userMusicList = (map[segaId] as List?) ?? []; + } + + _userScoreCache.clear(); + for (var group in userMusicList) { + final details = group['userMusicDetailList'] ?? []; + for (var d in details) { + int realMusicId = d['musicId']; // 完整ID + int level = d['level'] ?? 0; + _userScoreCache[realMusicId] ??= {}; + _userScoreCache[realMusicId]![level] = d; + } + } + setState(() {}); + } catch (e) { + print("加载成绩失败: $e"); + } + } + + // 搜索 + 过滤 + void _applyFilters() { + final q = _normalizeSearch(_searchQuery); + _displaySongs = _allSongs.where((song) { + if (q.isNotEmpty) { + bool match = false; + if (_normalizeSearch(song.title).contains(q)) match = true; + if (_normalizeSearch(song.artist).contains(q)) match = true; + if (song.id.toString().contains(q)) match = true; + for (var a in song.albums) { + if (_normalizeSearch(a).contains(q)) match = true; + } + if (!match) return false; + } + + if (_filterFromVersion != null && song.from != _filterFromVersion) return false; + if (_filterGenre != null && song.genre != _filterGenre) return false; + + if (_filterLevelType != null) { + bool hasLevel = false; + final allDiffs = _getAllDifficultiesWithType(song); + for (var d in allDiffs) { + if (d['diff']['level_id'] == _filterLevelType) { + hasLevel = true; + break; + } + } + if (!hasLevel) return false; + } + + if (_selectedMinLevel != null || _selectedMaxLevel != null) { + bool inRange = false; + final allDiffs = _getAllDifficultiesWithType(song); + for (var d in allDiffs) { + final lv = double.tryParse(d['diff']['level_value']?.toString() ?? ''); + if (lv == null) continue; + final minOk = _selectedMinLevel == null || lv >= _selectedMinLevel!; + final maxOk = _selectedMaxLevel == null || lv <= _selectedMaxLevel!; + if (minOk && maxOk) { + inRange = true; + break; + } + } + if (!inRange) return false; + } + + return true; + }).toList(); + setState(() {}); + } + + String _normalizeSearch(String? s) { + if (s == null) return ''; + return s.trim().toLowerCase(); + } + + // 带类型的难度(SD/DX/UT) + List> _getAllDifficultiesWithType(SongModel song) { + List> all = []; + if (song.sd != null && song.sd!.isNotEmpty) { + for (var d in song.sd!.values) { + all.add({'type': 'SD', 'diff': d}); + } + } + if (song.dx != null && song.dx!.isNotEmpty) { + for (var d in song.dx!.values) { + all.add({'type': 'DX', 'diff': d}); + } + } + if (song.ut != null && song.ut!.isNotEmpty) { + for (var d in song.ut!.values) { + all.add({'type': 'UT', 'diff': d}); + } + } + return all; + } + + // 根据歌曲类型获取真实完整ID + int _getRealMusicId(SongModel song, String type) { + if (type == "SD") { + return song.id; + } else if (type == "DX") { + return 10000 + song.id; + } else { + return 100000 + song.id; + } + } + + void _resetFilters() { + setState(() { + _searchQuery = ''; + _filterLevelType = null; + _filterFromVersion = null; + _filterGenre = null; + _selectedMinLevel = null; + _selectedMaxLevel = null; + }); + _minLevelScrollController.jumpToItem(0); + _maxLevelScrollController.jumpToItem(_levelOptions.length - 1); + _applyFilters(); + } + + 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"; + } + + void _showLevelPickerDialog() { + int minIdx = _selectedMinLevel != null ? _levelOptions.indexOf(_selectedMinLevel!) : 0; + int maxIdx = _selectedMaxLevel != null ? _levelOptions.indexOf(_selectedMaxLevel!) : _levelOptions.length - 1; + + showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))), + builder: (context) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _minLevelScrollController.jumpToItem(minIdx); + _maxLevelScrollController.jumpToItem(maxIdx); + }); + + 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 min = _levelOptions[_minLevelScrollController.selectedItem]; + final max = _levelOptions[_maxLevelScrollController.selectedItem]; + setState(() { + _selectedMinLevel = min; + _selectedMaxLevel = max; + _applyFilters(); + }); + Navigator.pop(context); + }, + child: const Text("确定", style: TextStyle(fontWeight: FontWeight.bold)), + ), + ], + ), + ), + const Divider(), + Expanded( + child: Row( + children: [ + Expanded( + child: Column( + children: [ + const Text("最小定数", style: TextStyle(fontSize: 12, color: Colors.grey)), + Expanded( + child: CupertinoPicker( + scrollController: _minLevelScrollController, + itemExtent: 40, + onSelectedItemChanged: (_) {}, + children: _levelOptions.map((e) => Center(child: Text(e.toStringAsFixed(1)))).toList(), + ), + ), + ], + ), + ), + Expanded( + child: Column( + children: [ + const Text("最大定数", style: TextStyle(fontSize: 12, color: Colors.grey)), + Expanded( + child: CupertinoPicker( + scrollController: _maxLevelScrollController, + itemExtent: 40, + onSelectedItemChanged: (_) {}, + children: _levelOptions.map((e) => Center(child: Text(e.toStringAsFixed(1)))).toList(), + ), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ); + }, + ); + } + + @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: () async { + setState(() => _isRefreshing = true); + await _loadAllSongs(); + await _loadUserScores(); + setState(() => _isRefreshing = false); + }) + ], + ), + body: Column( + children: [ + _buildFilterBar(), + Expanded(child: _buildBody()), + ], + ), + ); + } + + 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: (v) { + setState(() => _searchQuery = v); + _applyFilters(); + }, + ), + ), + IconButton( + icon: Icon(_isAdvancedFilterExpanded ? Icons.expand_less : Icons.expand_more), + onPressed: () { + setState(() { + _isAdvancedFilterExpanded = !_isAdvancedFilterExpanded; + _isAdvancedFilterExpanded ? _animationController.forward() : _animationController.reverse(); + }); + }, + ), + if (_hasFilters()) + 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: (v) { + setState(() => _filterLevelType = v); + _applyFilters(); + }, + ), + ), + ], + ), + ), + SizeTransition( + sizeFactor: _animation, + axisAlignment: -1, + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration(color: Colors.grey.withAlpha(20), border: Border(top: BorderSide(color: Colors.grey.shade300))), + child: Column( + 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: (v) { + setState(() => _filterFromVersion = v); + _applyFilters(); + }, + ), + ), + 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: (v) { + setState(() => _filterGenre = v); + _applyFilters(); + }, + ), + ), + ], + ), + 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: [ + const Text("定数范围", style: TextStyle(fontWeight: FontWeight.bold)), + Text(_getLevelRangeText(), style: TextStyle(color: Colors.grey[600])), + const Icon(Icons.arrow_drop_down, color: Colors.grey), + ], + ), + ), + ), + ], + ), + ), + ), + ], + ), + ); + } + String getLeftEllipsisText(String text, int maxLength) { + if (text.length <= maxLength) return text; + return '...${text.substring(text.length - maxLength + 3)}'; // +3 是因为 "..." 占3个字符 + } + Widget _buildDropdown({required String label, required dynamic value, required List items, required ValueChanged onChanged}) { + return DropdownButtonFormField( + value: value, + isExpanded: true, + decoration: InputDecoration(labelText: label, border: const OutlineInputBorder(), contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), isDense: false), + items: items, + onChanged: onChanged, + ); + } + + bool _hasFilters() => _searchQuery.isNotEmpty || _filterLevelType != null || _filterFromVersion != null || _filterGenre != null || _selectedMinLevel != null || _selectedMaxLevel != null; + + Widget _buildBody() { + if (_isLoading) return const Center(child: CircularProgressIndicator()); + if (_errorMessage.isNotEmpty) return Center(child: Text(_errorMessage, style: const TextStyle(color: Colors.red))); + if (_displaySongs.isEmpty) return const Center(child: Text("无匹配歌曲")); + + return ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + itemCount: _displaySongs.length, + itemBuilder: (_, idx) { + final song = _displaySongs[idx]; + return _songItem(song); + }, + ); + } + + Widget _songItem(SongModel song) { + final cover = _getCoverUrl(song.id); + bool hasScore = false; + + // 检查SD/DX/UT任意一个有成绩 + if (song.sd != null && _userScoreCache.containsKey(song.id)) hasScore = true; + if (song.dx != null && _userScoreCache.containsKey(10000 + song.id)) hasScore = true; + if (song.ut != null && _userScoreCache.containsKey(100000 + song.id)) hasScore = true; + + // --- 新增:处理别名逻辑 --- + // 1. 获取原始列表 + List rawAliases = song.albums ?? []; + + // 2. 去重 (保留插入顺序可以使用 LinkedHashSet,或者简单转为 Set 再转 List) + // 注意:Set.from 可能会打乱顺序,如果顺序不重要可以直接用。 + // 如果希望保持原顺序去重: + final seen = {}; + final uniqueAliases = rawAliases.where((alias) => seen.add(alias)).toList(); + + // 3. 过滤掉与标题或艺术家完全相同的重复项(可选,为了更整洁) + final filteredAliases = uniqueAliases.where((alias) => + alias != song.title && + alias != song.artist + ).toList(); + + // 4. 拼接字符串,如果太长则截断 + // 我们设定一个最大显示长度或者最大行数,这里采用“尝试放入尽可能多的词,直到超过一定长度”的策略 + String aliasDisplayText = ""; + if (filteredAliases.isNotEmpty) { + // 尝试加入前 N 个别名,总长度控制在合理范围,例如 60-80 个字符,或者固定个数如 5-8 个 + // 这里采用固定个数策略,因为每个词长度不一,固定个数更容易预测高度 + int maxAliasCount = 6; + List displayList = filteredAliases.take(maxAliasCount).toList(); + + aliasDisplayText = displayList.join(" · "); + + // 如果还有更多未显示的,加上省略提示 + if (filteredAliases.length > maxAliasCount) { + aliasDisplayText += " ..."; + } + } + // ------------------------- + + return Card( + margin: const EdgeInsets.only(bottom: 10), + elevation: 4, + shadowColor: Colors.purpleAccent.withOpacity(0.5), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: InkWell( + onTap: () { + Navigator.push( + context, + MaterialPageRoute(builder: (_) => SongDetailPage( + song: song, + userScoreCache: _userScoreCache, + )), + ); + }, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 左侧:封面 + ID 区域 + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(2), + child: Image.network( + cover, + width: 100, + height: 100, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => Container( + width: 60, + height: 60, + color: Colors.grey[200], + child: const Icon(Icons.music_note), + ), + ), + ), + const SizedBox(height: 2), + // 这一行:id + 左侧标签 + Row( + mainAxisAlignment: MainAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // 标签区域(cn / jp / m2l / long) + if (song.cn == true) + _buildTag( + "CN", + backgroundColor: Colors.redAccent, + shadowColor: Colors.red.withOpacity(0.3), + ), + if (song.jp == true) + _buildTag( + "JP", + backgroundColor: Colors.blueAccent, + shadowColor: Colors.red.withOpacity(0.3), + ), + if (song.m2l == true) + _buildTag( + "M2L", + gradient: const LinearGradient( + colors: [Colors.purple, Colors.blueAccent], + begin: Alignment.centerLeft, + end: Alignment.centerRight, + ), + ), + if (song.long == true) + _buildTag( + "LONG", + gradient: const LinearGradient( + colors: [Colors.black12, Colors.lightBlueAccent], + begin: Alignment.topRight, + end: Alignment.bottomLeft, + ), + + ), + + // id 文本 + Text( + " ${song.id}", + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 11, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + SizedBox( + width: 100, + child: Text( + getLeftEllipsisText("${song.from}", 15), + style: const TextStyle( + fontSize: 9, + color: Colors.grey, + ), + maxLines: 1, + textAlign: TextAlign.right, + overflow: TextOverflow.visible, + ), + ) + ], + ), + const SizedBox(width: 12), + + // 右侧:详细信息 + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + // 关键:让内容在垂直方向上尽量分布,或者使用 MainAxisSize.min 配合 Spacer + mainAxisSize: MainAxisSize.min, + children: [ + Text( + song.title, + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + song.artist ?? "Unknown", + style: TextStyle(fontSize: 12, color: Colors.grey[600]), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 6), + ..._getDifficultyChipsByType(song), + if (aliasDisplayText.isNotEmpty) ...[ + const SizedBox(height: 6), + LayoutBuilder( + builder: (context, constraints) { + // 可选:根据剩余宽度动态决定显示多少行,这里简单处理为最多2行 + return Text( + aliasDisplayText, + style: TextStyle( + fontSize: 10, + color: Colors.grey[500], + height: 1.2, // 紧凑行高 + ), + maxLines: 5, + overflow: TextOverflow.ellipsis, + ); + }, + ), + ], + ], + ), + ), + + if (hasScore) + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3), + decoration: BoxDecoration(color: Colors.greenAccent.withOpacity(0.15), borderRadius: BorderRadius.circular(4)), + child: const Text("Score", style: TextStyle(color: Colors.green, fontSize: 12, fontWeight: FontWeight.bold)), + ), + ], + ), + ), + ), + ); + } + Widget _buildTag( + String text, { + // 背景:纯色 或 渐变 二选一 + Color? backgroundColor, + Gradient? gradient, + + // 阴影配置 + Color? shadowColor, + double shadowBlurRadius = 2, + Offset shadowOffset = const Offset(0, 1), + + // 可选样式扩展 + double borderRadius = 2, + double fontSize = 8, + Color textColor = Colors.white, + }) { + return Container( + margin: const EdgeInsets.only(right: 3), + padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 1), + decoration: BoxDecoration( + // 优先使用渐变,没有渐变则使用纯色 + color: gradient == null ? (backgroundColor ?? Colors.blueAccent) : null, + gradient: gradient, + borderRadius: BorderRadius.circular(borderRadius), + // 阴影:只有传入 shadowColor 才显示 + boxShadow: shadowColor != null + ? [ + BoxShadow( + color: shadowColor, + blurRadius: shadowBlurRadius, + offset: shadowOffset, + ) + ] + : null, + ), + child: Text( + text, + style: TextStyle( + color: textColor, + fontSize: fontSize, + fontWeight: FontWeight.bold, + ), + ), + ); + } + List _getDifficultyChipsByType(SongModel song) { + final diffs = _getAllDifficultiesWithType(song); + + // 按 type 分组:SD / DX / UT + final Map> typeGroups = {}; + for (var item in diffs) { + final type = item['type'] as String; + typeGroups.putIfAbsent(type, () => []); + typeGroups[type]!.add(item); + } + + // 固定顺序:SD → DX → UT + final order = ['SD', 'DX', 'UT']; + List rows = []; + + for (final type in order) { + final items = typeGroups[type]; + if (items == null || items.isEmpty) continue; + + // 每一个类型 = 一行 Wrap + final row = Wrap( + spacing: 6, + runSpacing: 4, + children: items.map((item) { + final diff = item['diff'] as Map; + final lid = diff['level_id'] as int; + final lvValue = double.tryParse(diff['level_value']?.toString() ?? '') ?? 0; + + bool isBanquet = lvValue == 10.0 || song.id > 100000 || type == 'UT'; + int realId = _getRealMusicId(song, type); + bool hasScore = _userScoreCache[realId]?[lid] != null; + + Color color = Colors.grey; + String label = ""; + + if (isBanquet) { + color = Colors.pinkAccent; + + // 多UT按ID显示对应名称 + if (type == 'UT') { + final utTitleMap = song.utTitle as Map?; + if (utTitleMap != null && utTitleMap.isNotEmpty) { + final key = item["diff"]['id'].toString(); + if (utTitleMap.containsKey(key)) { + label = utTitleMap[key].toString(); + } else { + label = "UT"; + } + } else { + label = "UT"; + } + } else { + label = type; + } + } else { + switch (lid) { + case 0: + color = Colors.green; + label = type; + break; + case 1: + color = Colors.yellow.shade700; + label = type; + break; + case 2: + color = Colors.red; + label = type; + break; + case 3: + color = Colors.purple; + label = type; + break; + case 4: + color = Colors.purpleAccent.shade100; + label = type; + break; + default: + return const SizedBox(); + } + } + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), + decoration: BoxDecoration( + color: color.withOpacity(hasScore ? 0.25 : 0.1), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: color.withOpacity(0.5)), + ), + child: Text( + isBanquet ? label : "$label ${lvValue.toStringAsFixed(1)}", + style: TextStyle( + color: color, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ); + }).toList(), + ); + + rows.add(row); + rows.add(const SizedBox(height: 4)); + } + + return rows; + } + + 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"; + } + } +} diff --git a/lib/pages/music/score_single.dart b/lib/pages/music/score_single.dart new file mode 100644 index 0000000..e9c0a62 --- /dev/null +++ b/lib/pages/music/score_single.dart @@ -0,0 +1,569 @@ +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 '../../../widgets/score_progress_chart.dart'; + +class SongDetailPage extends StatefulWidget { + final SongModel song; + final Map> userScoreCache; + + const SongDetailPage({ + super.key, + required this.song, + required this.userScoreCache, + }); + + @override + State createState() => _SongDetailPageState(); +} + +class _SongDetailPageState extends State { + String? _selectedType; + + // 缓存图表数据: Key为 "SD" / "DX" / "UT_realId" + final Map>> _chartDataCache = {}; + final Map _isLoadingChart = {}; + + @override + void initState() { + super.initState(); + _initSelectedType(); + } + + void _initSelectedType() { + if (widget.song.sd != null && widget.song.sd!.isNotEmpty) { + _selectedType = 'SD'; + } else if (widget.song.dx != null && widget.song.dx!.isNotEmpty) { + _selectedType = 'DX'; + } else if (widget.song.ut != null && widget.song.ut!.isNotEmpty) { + _selectedType = 'UT'; + } + } + + List _getAvailableTypes() { + List types = []; + if (widget.song.sd != null && widget.song.sd!.isNotEmpty) types.add('SD'); + if (widget.song.dx != null && widget.song.dx!.isNotEmpty) types.add('DX'); + if (widget.song.ut != null && widget.song.ut!.isNotEmpty) types.add('UT'); + return types; + } + + List> _getCurrentDifficulties() { + List> all = []; + if (_selectedType == 'SD' && widget.song.sd != null) { + for (var d in widget.song.sd!.values) { + all.add({'type': 'SD', 'diff': d}); + } + } else if (_selectedType == 'DX' && widget.song.dx != null) { + for (var d in widget.song.dx!.values) { + all.add({'type': 'DX', 'diff': d}); + } + } else if (_selectedType == 'UT' && widget.song.ut != null) { + for (var d in widget.song.ut!.values) { + all.add({'type': 'UT', 'diff': d}); + } + } + all.sort((a, b) { + int idA = a['diff']['level_id'] ?? 0; + int idB = b['diff']['level_id'] ?? 0; + return idA.compareTo(idB); + }); + return all; + } + + // 核心:SD/DX 只加载一次,UT 每个谱面加载一次 + Future _loadTypeChartData(String type) async { + // 已经加载/正在加载 → 直接返回 + if (_chartDataCache.containsKey(type) || _isLoadingChart[type] == true) { + return; + } + + setState(() { + _isLoadingChart[type] = true; + }); + + try { + final userProvider = UserProvider.instance; + final token = userProvider.token; + + if (token == null || token.isEmpty) { + setState(() => _isLoadingChart[type] = false); + return; + } + + // 获取当前类型的 API ID + int apiMusicId; + if (type == 'SD') { + apiMusicId = widget.song.id; + } else if (type == 'DX') { + apiMusicId = 10000 + widget.song.id; + } else { + setState(() => _isLoadingChart[type] = false); + return; + } + + // 调用一次 API,拿到全部难度数据 + final logs = await UserService.getChartLog(token, [apiMusicId]); + logs.sort((a, b) => a.time.compareTo(b.time)); + + // 转换数据 + final chartList = logs.map((log) => { + 'achievement': log.segaChartNew?.achievement ?? 0, + 'time': log.time, + 'scoreRank': log.segaChartNew?.scoreRank ?? 0, + 'comboStatus': log.segaChartNew?.comboStatus ?? 0, + 'syncStatus': log.segaChartNew?.syncStatus ?? 0, + 'deluxscoreMax': log.segaChartNew?.deluxscoreMax ?? 0, + 'playCount': log.segaChartNew?.playCount ?? 0, + 'level': log.segaChartNew?.level ?? 0, // 保存难度等级用于过滤 + }).toList(); + + setState(() { + _chartDataCache[type] = chartList; + _isLoadingChart[type] = false; + }); + } catch (e) { + print("Load chart error: $e"); + setState(() => _isLoadingChart[type] = false); + } + } + + // UT 单独加载 + Future _loadUtChartData(String cacheKey, int realId) async { + if (_chartDataCache.containsKey(cacheKey) || _isLoadingChart[cacheKey] == true) { + return; + } + + setState(() => _isLoadingChart[cacheKey] = true); + + try { + final userProvider = UserProvider.instance; + final token = userProvider.token; + if (token == null || token.isEmpty) { + setState(() => _isLoadingChart[cacheKey] = false); + return; + } + + final logs = await UserService.getChartLog(token, [realId]); + logs.sort((a, b) => a.time.compareTo(b.time)); + + final chartList = logs.map((log) => { + 'achievement': log.segaChartNew?.achievement ?? 0, + 'time': log.time, + 'scoreRank': log.segaChartNew?.scoreRank ?? 0, + 'comboStatus': log.segaChartNew?.comboStatus ?? 0, + 'syncStatus': log.segaChartNew?.syncStatus ?? 0, + 'deluxscoreMax': log.segaChartNew?.deluxscoreMax ?? 0, + 'playCount': log.segaChartNew?.playCount ?? 0, + }).toList(); + + setState(() { + _chartDataCache[cacheKey] = chartList; + _isLoadingChart[cacheKey] = false; + }); + } catch (e) { + print("Load UT chart error: $e"); + setState(() => _isLoadingChart[cacheKey] = false); + } + } + + @override + Widget build(BuildContext context) { + final coverUrl = _getCoverUrl(widget.song.id); + final diffs = _getCurrentDifficulties(); + final availableTypes = _getAvailableTypes(); + + return Scaffold( + appBar: AppBar(title: Text(widget.song.title ?? "歌曲详情")), + body: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 歌曲封面 + 信息 + Row( + crossAxisAlignment: CrossAxisAlignment.start, + // 修复:这里缺少了括号 + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Image.network( + coverUrl, + width: 120, + height: 120, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => Container( + width: 120, + height: 120, + color: Colors.grey[200], + child: const Icon(Icons.music_note, size: 40), + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "[${widget.song.id}] ${widget.song.title}", + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Text( + "艺术家:${widget.song.artist ?? '未知'}", + style: const TextStyle(fontSize: 14), + ), + Text( + "流派:${widget.song.genre} | 版本:${widget.song.from}", + style: const TextStyle(fontSize: 13, color: Colors.grey), + ), + Text( + "BPM:${widget.song.bpm ?? '未知'}", + style: const TextStyle(fontSize: 13, color: Colors.grey), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 24), + + // 难度选择器 + if (availableTypes.length > 1) ...[ + Row( + children: [ + const Text( + "难度详情", + style: TextStyle( + fontSize: 17, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(width: 10), + Expanded( + child: CupertinoSlidingSegmentedControl( + groupValue: _selectedType, + backgroundColor: Colors.grey.shade200, + thumbColor: Theme.of(context).primaryColor.withOpacity(0.8), + children: { + for (var type in availableTypes) + type: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Text( + type, + style: TextStyle( + color: _selectedType == type ? Colors.white : Colors.black87, + fontWeight: FontWeight.bold, + ), + ), + ), + }, + onValueChanged: (value) { + if (value != null) { + setState(() { + _selectedType = value; + }); + } + }, + ), + ), + ], + ), + ] else ...[ + const Text( + "难度详情", + style: TextStyle(fontSize: 17, fontWeight: FontWeight.bold), + ), + ], + const SizedBox(height: 12), + + // 难度列表 + if (diffs.isEmpty) + Center( + child: Padding( + padding: const EdgeInsets.all(20.0), + child: Text( + "暂无 ${_selectedType ?? ''} 谱面数据", + style: const TextStyle(color: Colors.grey), + ), + ), + ) + else + ...diffs.map((item) => _diffItem( + type: item['type'], + diff: item['diff'], + )).toList(), + + const SizedBox(height: 30), + ], + ), + ), + ); + } + + Widget _diffItem({required String type, required Map diff}) { + int levelId = diff['level_id'] ?? 0; + final double lvValue = + double.tryParse(diff['level_value']?.toString() ?? '') ?? 0; + final designer = diff['note_designer'] ?? "-"; + final notes = diff['notes'] ?? {}; + final total = notes['total'] ?? 0; + final tap = notes['tap'] ?? 0; + final hold = notes['hold'] ?? 0; + final slide = notes['slide'] ?? 0; + final brk = notes['break_'] ?? 0; + + int realId; + String? utTitleName; + String cacheKey = ""; + bool hasScoreData = false; + + // 加载逻辑:SD/DX 只加载一次,UT 每个加载一次 + if (type == 'UT') { + realId = (diff['id'] as num?)?.toInt() ?? 0; + final utTitleMap = widget.song.utTitle as Map?; + if (utTitleMap != null) { + final key = diff['id'].toString(); + utTitleName = utTitleMap[key]?.toString(); + } + cacheKey = "UT_$realId"; + WidgetsBinding.instance.addPostFrameCallback((_) { + _loadUtChartData(cacheKey, realId); + }); + } else { + realId = _getRealMusicId(type); + cacheKey = type; // SD/DX 用类型做缓存 key + WidgetsBinding.instance.addPostFrameCallback((_) { + _loadTypeChartData(type); + }); + } + + // 图表数据:SD/DX 自动过滤当前难度,UT 直接使用 + List> chartHistory = []; + if (_chartDataCache.containsKey(cacheKey)) { + if (type == 'UT') { + chartHistory = _chartDataCache[cacheKey]!; + } else { + // SD/DX 从全量数据中过滤当前难度 + chartHistory = _chartDataCache[cacheKey]! + .where((e) => e['level'] == levelId) + .toList(); + } + } + hasScoreData = chartHistory.isNotEmpty; + + // 成绩信息 + final score = widget.userScoreCache[realId]?[levelId]; + bool hasUserScore = score != null; + + // 显示名称与颜色 + String name = ""; + Color color = Colors.grey; + bool isBanquet = type == 'UT'; + + if (isBanquet) { + color = Colors.pinkAccent; + name = utTitleName ?? "UT 宴会谱"; + } else { + switch (levelId) { + case 0: name = "$type Basic"; color = Colors.green; break; + case 1: name = "$type Advanced"; color = Colors.yellow.shade700; break; + case 2: name = "$type Expert"; color = Colors.red; break; + case 3: name = "$type Master"; color = Colors.purple; break; + case 4: name = "$type Re:Master"; color = Colors.purpleAccent.shade100; break; + default: name = type; + } + } + + return Card( + margin: const EdgeInsets.only(bottom: 10), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: color.withOpacity(0.15), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: color.withOpacity(0.3)), + ), + child: Text( + isBanquet ? name : "$name (${lvValue.toStringAsFixed(1)})", + style: TextStyle( + color: color, + fontWeight: FontWeight.bold, + fontSize: 15, + ), + ), + ), + if (hasUserScore) + const Icon(Icons.star, color: Colors.amber, size: 20), + ], + ), + const SizedBox(height: 8), + Text("谱师:$designer", style: const TextStyle(fontSize: 13)), + const SizedBox(height: 4), + Text( + "物量:$total | TAP:$tap HOLD:$hold SLIDE:$slide BRK:$brk", + style: const TextStyle(color: Colors.grey, fontSize: 12), + ), + + const SizedBox(height: 12), + // ✅ 修复:没有成绩直接显示文字,绝不显示加载圈 + if (hasScoreData) + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text("推分进程", style: TextStyle(fontSize: 12, color: Colors.grey)), + Text( + "最近: ${(chartHistory.last['achievement'] / 10000.0).toStringAsFixed(4)}%", + style: TextStyle(fontSize: 12, color: color), + ), + ], + ), + const SizedBox(height: 4), + ScoreProgressChart( + historyScores: chartHistory, + lineColor: color, + fillColor: color, + ), + ], + ) + else + const Text("暂无历史记录", style: TextStyle(fontSize: 12, color: Colors.grey)), + + if (hasUserScore) ...[ + const SizedBox(height: 10), + const Divider(height: 1), + const SizedBox(height: 6), + _buildScoreInfo(score!), + ], + ], + ), + ), + ); + } + + Widget _buildScoreInfo(Map score) { + final ach = (score['achievement'] ?? 0) / 10000; + final rank = _getRankText(score['scoreRank'] ?? 0); + final combo = _comboText(score['comboStatus'] ?? 0); + final sync = _syncText(score['syncStatus'] ?? 0); + final dxScore = score['deluxscoreMax'] ?? 0; + final playCount = score['playCount'] ?? 0; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text("你的成绩:", + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), + const SizedBox(height: 4), + Row( + children: [ + Text("达成率:${ach.toStringAsFixed(4)}%", + style: TextStyle( + color: _getColorByAchievement(ach), + fontWeight: FontWeight.bold)), + const Spacer(), + Text("评级:$rank", + style: TextStyle( + color: _getColorByRank(rank), fontWeight: FontWeight.bold)), + ], + ), + const SizedBox(height: 2), + Text("Combo:$combo | Sync:$sync", + style: const TextStyle(fontSize: 12, color: Colors.blueGrey)), + Text("DX分数:$dxScore | 游玩次数:$playCount", + style: const TextStyle(fontSize: 12, color: Colors.grey)), + ], + ); + } + + Color _getColorByAchievement(double ach) { + if (ach >= 100.5) return const Color(0xFFD4AF37); + if (ach >= 100.0) return Colors.purple; + if (ach >= 99.5) return Colors.purpleAccent; + if (ach >= 99.0) return Colors.deepPurple; + if (ach >= 98.0) return Colors.lightBlue; + if (ach >= 97.0) return Colors.blue; + return Colors.green; + } + + Color _getColorByRank(String rank) { + if (rank.contains("SSS")) return Colors.purple; + if (rank.contains("SS")) return Colors.deepPurple; + if (rank.contains("S")) return Colors.blue; + return Colors.green; + } + + 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"; + } + } + + 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"; + } + } + + String _comboText(int s) { + switch (s) { + case 1: return "FC"; + case 2: return "FC+"; + case 3: return "AP"; + case 4: return "AP+"; + default: return "无"; + } + } + + String _syncText(int s) { + switch (s) { + case 1: return "FS"; + case 2: return "FS+"; + case 3: return "FDX"; + case 4: return "FDX+"; + default: return "无"; + } + } + + int _getRealMusicId(String type) { + if (type == "SD") return widget.song.id; + if (type == "DX") return 10000 + widget.song.id; + return 100000 + widget.song.id; + } +} \ No newline at end of file diff --git a/lib/pages/songlistpage.dart b/lib/pages/songlistpage.dart deleted file mode 100644 index 1855d87..0000000 --- a/lib/pages/songlistpage.dart +++ /dev/null @@ -1,15 +0,0 @@ -import 'package:flutter/material.dart'; - -class SongListPage extends StatelessWidget { - const SongListPage({super.key}); - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: const Text("歌曲列表")), - body: const Center( - child: Text("歌曲列表页面 - 可自由编写 UI", style: TextStyle(fontSize: 22)), - ), - ); - } -} \ No newline at end of file diff --git a/lib/service/user_service.dart b/lib/service/user_service.dart index 7d3ed68..6e27c2a 100644 --- a/lib/service/user_service.dart +++ b/lib/service/user_service.dart @@ -1,5 +1,6 @@ import 'dart:convert'; import 'package:dio/dio.dart'; +import '../model/song_model.dart'; import '../model/user_model.dart'; import '../tool/encryption_util.dart'; @@ -307,4 +308,33 @@ class UserService { throw _getErrorMessage(e); } } + + static Future> getChartLog(String token, List musicIds) async { + try { + print("getChartLog"); + // 构建查询参数 + // 如果 API 期望的是逗号分隔的字符串 "11434,11435" + final String idsParam = musicIds.map((e) => e.toString()).join(','); + + final res = await _dio.get( + '$baseUrl/api/union/chartlog', + queryParameters: { + "musicId": idsParam, + }, + options: Options(headers: {"Authorization": token}), + ); + + // 检查返回数据是否为 List + if (res.data is List) { + return (res.data as List) + .map((item) => ChartLogModel.fromJson(item as Map)) + .toList(); + } + + // 如果后端在某些错误情况下返回 Map 而不是 List,这里返回空列表或抛出异常 + return []; + } on DioException catch (e) { + throw _getErrorMessage(e); + } + } } \ No newline at end of file diff --git a/lib/widgets/score_progress_chart.dart b/lib/widgets/score_progress_chart.dart new file mode 100644 index 0000000..42fd47b --- /dev/null +++ b/lib/widgets/score_progress_chart.dart @@ -0,0 +1,156 @@ +import 'package:flutter/material.dart'; +import 'dart:math'; + +class ScoreProgressChart extends StatelessWidget { + // 历史成绩列表,按时间排序 + final List> historyScores; + final double height; + final Color lineColor; + final Color fillColor; + + const ScoreProgressChart({ + super.key, + required this.historyScores, + this.height = 60.0, + this.lineColor = Colors.blue, + this.fillColor = Colors.blueAccent, + }); + + @override + Widget build(BuildContext context) { + if (historyScores.isEmpty) { + return SizedBox(height: height); + } + + // 提取达成率数据 (achievement / 10000) + // 假设 historyScores 已经按时间正序排列 + final List achievements = historyScores.map((s) { + int ach = s['achievement'] ?? 0; + return ach / 10000.0; + }).toList(); + + // 找出最小值和最大值以确定 Y 轴范围,为了美观,稍微扩大一点范围 + double minVal = achievements.reduce(min); + double maxVal = achievements.reduce(max); + + // 如果所有分数一样,给一个默认范围 + if (minVal == maxVal) { + minVal = max(0, minVal - 1.0); + maxVal = min(101.0, maxVal + 1.0); + } else { + // 增加一点上下边距 + double range = maxVal - minVal; + minVal = max(0, minVal - range * 0.1); + maxVal = min(101.0, maxVal + range * 0.1); + } + + return SizedBox( + height: height, + child: CustomPaint( + size: Size.infinite, + painter: _ChartPainter( + achievements: achievements, + minVal: minVal, + maxVal: maxVal, + lineColor: lineColor, + fillColor: fillColor.withOpacity(0.2), + ), + ), + ); + } +} + +class _ChartPainter extends CustomPainter { + final List achievements; + final double minVal; + final double maxVal; + final Color lineColor; + final Color fillColor; + + _ChartPainter({ + required this.achievements, + required this.minVal, + required this.maxVal, + required this.lineColor, + required this.fillColor, + }); + + @override + void paint(Canvas canvas, Size size) { + // ✅ 修复1:数据不足直接返回 + if (achievements.length < 1) return; + + final paint = Paint() + ..color = lineColor + ..strokeWidth = 2.0 + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.round; + + final fillPaint = Paint() + ..color = fillColor + ..style = PaintingStyle.fill; + + final path = Path(); + final fillPath = Path(); + + double width = size.width; + double height = size.height; + double range = maxVal - minVal; + if (range == 0) range = 1; // 防止除以0 + + // 计算每个点的坐标 + List points = []; + for (int i = 0; i < achievements.length; i++) { + double x; + if (achievements.length == 1) { + x = width / 2; // ✅ 修复2:只有1个点时,放中间 + } else { + x = (i / (achievements.length - 1)) * width; + } + + // Y轴翻转,因为Canvas原点在左上角 + double normalizedY = (achievements[i] - minVal) / range; + double y = height - (normalizedY * height); + + // ✅ 修复3:防止 NaN / 无限大 + if (x.isNaN || x.isInfinite) x = 0; + if (y.isNaN || y.isInfinite) y = height / 2; + + points.add(Offset(x, y)); + } + + // 构建折线路径 + path.moveTo(points.first.dx, points.first.dy); + fillPath.moveTo(points.first.dx, height); // 填充从底部开始 + fillPath.lineTo(points.first.dx, points.first.dy); + + for (int i = 1; i < points.length; i++) { + path.lineTo(points[i].dx, points[i].dy); + fillPath.lineTo(points[i].dx, points[i].dy); + } + + // 闭合填充路径 + fillPath.lineTo(points.last.dx, height); + fillPath.close(); + + // 绘制填充 + canvas.drawPath(fillPath, fillPaint); + // 绘制折线 + canvas.drawPath(path, paint); + + // 可选:绘制最后一个点的高亮圆点 + final dotPaint = Paint()..color = lineColor; + final lastPoint = points.last; + // ✅ 修复4:绘制前校验,彻底杜绝NaN崩溃 + if (!lastPoint.dx.isNaN && !lastPoint.dy.isNaN) { + canvas.drawCircle(lastPoint, 3.0, dotPaint); + } + } + + @override + bool shouldRepaint(covariant _ChartPainter oldDelegate) { + return oldDelegate.achievements != achievements || + oldDelegate.minVal != minVal || + oldDelegate.maxVal != maxVal; + } +} \ No newline at end of file