diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index b5de554..5dd468d 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -44,5 +44,9 @@ + + + + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 24000c4..b08ddbc 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -73,5 +73,10 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight + LSApplicationQueriesSchemes + + mqq + + \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index 4b7e01f..e518522 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -8,7 +8,6 @@ void main() { WidgetsFlutterBinding.ensureInitialized(); HardwareKeyboard.instance.clearState(); - // 🔥 不 await!直接取实例 final userProvider = UserProvider.instance; // 🔥 后台异步初始化,不卡界面 diff --git a/lib/pages/home/home_page.dart b/lib/pages/home/home_page.dart index 7f25b79..d79171a 100644 --- a/lib/pages/home/home_page.dart +++ b/lib/pages/home/home_page.dart @@ -2,13 +2,17 @@ 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/cacheImage.dart'; import '../../tool/gradientText.dart'; +import '../music/adx.dart'; +import '../music/score_single.dart'; import '../score/updateScorePage.dart'; import '../user/userpage.dart'; import '../scorelist.dart'; import 'package:provider/provider.dart'; import '../../providers/user_provider.dart'; import '../../model/song_model.dart'; +import 'package:url_launcher/url_launcher.dart'; class HomePage extends StatelessWidget { final Function(int)? onSwitchTab; @@ -177,7 +181,7 @@ class HomePage extends StatelessWidget { children: [ // 左侧:智能推荐乐曲(占 6 份宽度) Expanded( - flex: 6, + flex: 7, child: const _RecommendedSongsSection(), ), const SizedBox(width: 4), @@ -249,7 +253,14 @@ class HomePage extends StatelessWidget { offset: const Offset(2, 4), ), ], - onTap: () {}, + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => AdxDownloadGridPage(), + ), + ); + }, ), // 橙色渐变 @@ -269,7 +280,27 @@ class HomePage extends StatelessWidget { offset: const Offset(2, 4), ), ], - onTap: () {}, + onTap: () async { + // 你的固定分享链接 + const url = "https://bot.q.qq.com/s/c2mloqdgv?id=102172520"; + const title = "ReiSasol"; + + // QQ 分享 Scheme + final qqScheme = 'mqq://im/chat?chat_type=wpa&url=${Uri.encodeComponent(url)}&title=${Uri.encodeComponent(title)}'; + final uri = Uri.parse(qqScheme); + + try { + if (await canLaunchUrl(uri)) { + await launchUrl(uri); + } else { + // 未安装QQ → 用浏览器打开链接 + await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication); + } + } catch (e) { + // 兜底打开 + await launchUrl(Uri.parse(url)); + } + }, ), _quickActionItem( icon: Icons.link, @@ -452,7 +483,7 @@ class _UserInfoCard extends StatelessWidget { width: 60, height: 60, child: userProvider.avatarUrl.isNotEmpty - ? Image.network( + ? CacheImage.network( userProvider.avatarUrl, fit: BoxFit.cover, ) @@ -621,7 +652,6 @@ class _SongItemCard extends StatelessWidget { String _getCoverUrl(int musicId) { int displayId = musicId % 10000; - // 注意:这里逻辑可能需要根据你的实际资源调整,通常 DX 歌曲 ID > 10000 if (musicId >= 10000) { String idStr = displayId.toString().padLeft(6, '0'); return "https://u.mai2.link/jacket/UI_Jacket_$idStr.jpg"; @@ -630,116 +660,117 @@ class _SongItemCard extends StatelessWidget { } } - // 获取难度颜色 Color _getLevelColor(int levelIndex) { switch (levelIndex) { - case 0: return Colors.green; // Basic - case 1: return Colors.blue; // Advanced - case 2: return Colors.yellow[700]!; // Expert - case 3: return Colors.red; // Master - case 4: return Colors.purple; // Re:Master + case 0: return Colors.green; + case 1: return Colors.blue; + case 2: return Colors.yellow[700]!; + case 3: return Colors.red; + case 4: return Colors.purple; default: return Colors.grey; } } @override Widget build(BuildContext context) { - // 假设我们要显示 Master (3) 和 Re:Master (4) 的难度 - // 你需要从 song.sd 或 song.dx 中解析出具体的 level_value (定数) double? masterLv = _getLevelValue(song, 3); double? reMasterLv = _getLevelValue(song, 4); - return Container( - width: 140, - margin: const EdgeInsets.only(right: 12), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - boxShadow: [ - BoxShadow( - color: Colors.grey.withOpacity(0.15), - blurRadius: 8, - offset: const Offset(0, 4), + // ⭐ 核心:用 InkWell / GestureDetector 包裹整个卡片,实现点击跳转 + return InkWell( + borderRadius: BorderRadius.circular(12), + onTap: () { + // 跳转到歌曲详情页 + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => SongDetailPage( + song: song, + userScoreCache: {}, // 你可以根据实际页面传值 + ), ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // ✅ 1. 图片优化:增加 loadingBuilder 和 cacheWidth - ClipRRect( - borderRadius: const BorderRadius.vertical(top: Radius.circular(12)), - child: SizedBox( - height: 140, - width: double.infinity, - child: Image.network( - _getCoverUrl(song.id), - fit: BoxFit.cover, - // 关键优化:指定缓存宽度,减少内存占用和解码时间 - cacheWidth: 280, - loadingBuilder: (context, child, loadingProgress) { - if (loadingProgress == null) return child; - return Container( - color: Colors.grey[200], - child: Center(child: CircularProgressIndicator( - strokeWidth: 2, - value: loadingProgress.expectedTotalBytes != null - ? loadingProgress.cumulativeBytesLoaded / - loadingProgress.expectedTotalBytes! - : null, - )), - ); - }, - errorBuilder: (_, __, ___) { - return Container( - color: Colors.grey[200], - child: const Icon(Icons.music_note, size: 40, color: Colors.grey), - ); - }, + ); + }, + child: Container( + width: 140, + margin: const EdgeInsets.only(right: 12), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.15), + blurRadius: 8, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipRRect( + borderRadius: const BorderRadius.vertical(top: Radius.circular(12)), + child: SizedBox( + height: 140, + width: double.infinity, + child: CacheImage.network( + _getCoverUrl(song.id), + fit: BoxFit.cover, + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) return child; + return Container( + color: Colors.grey[200], + child: Center(child: CircularProgressIndicator( + strokeWidth: 2, + value: loadingProgress.expectedTotalBytes != null + ? loadingProgress.cumulativeBytesLoaded / + loadingProgress.expectedTotalBytes! + : null, + )), + ); + }, + ), ), ), - ), - const SizedBox(height: 8), + const SizedBox(height: 8), - // 标题 - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Text( - song.title ?? "未知歌曲", - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Text( + song.title ?? "未知歌曲", + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600), + ), ), - ), - // 艺术家 (修复了重复显示的问题) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Text( - song.artist ?? "未知艺术家", - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(fontSize: 11, color: Colors.grey[600]), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Text( + song.artist ?? "未知艺术家", + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 11, color: Colors.grey[600]), + ), ), - ), - const SizedBox(height: 6), + const SizedBox(height: 6), - // ✅ 2. 底部难度标签 - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - child: Row( - children: [ - if (masterLv != null) - _buildLevelTag("MAS", masterLv, Colors.purple), - if (reMasterLv != null) ...[ - const SizedBox(width: 4), - _buildLevelTag("ReM", reMasterLv, Colors.deepPurple), - ] - ], + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: Row( + children: [ + if (masterLv != null) + _buildLevelTag("MAS", masterLv, Colors.purple), + if (reMasterLv != null) ...[ + const SizedBox(width: 4), + _buildLevelTag("ReM", reMasterLv, Colors.deepPurple), + ] + ], + ), ), - ), - ], + ], + ), ), ); } @@ -763,18 +794,17 @@ class _SongItemCard extends StatelessWidget { ); } - // 辅助方法:获取定数 double? _getLevelValue(SongModel song, int levelIndex) { - Map ?diffMap = song.dx; - if (diffMap==null|| diffMap.isEmpty) { + Map? diffMap = song.dx; + if (diffMap == null || diffMap.isEmpty) { diffMap = song.sd; } - var data = diffMap?["$levelIndex"] ?? diffMap?[levelIndex]; - if (data is Map && data["level_value"] != null) { - return (data["level_value"] as num).toDouble(); - } - return null; + var data = diffMap?["$levelIndex"] ?? diffMap?[levelIndex]; + if (data is Map && data["level_value"] != null) { + return (data["level_value"] as num).toDouble(); + } + return null; } } @@ -804,7 +834,7 @@ class PosterImage extends StatelessWidget { ), child: ClipRRect( borderRadius: BorderRadius.circular(8), - child: Image.network( + child: CacheImage.network( imageUrl, fit: BoxFit.cover, loadingBuilder: (context, child, loadingProgress) { diff --git a/lib/pages/music/adx.dart b/lib/pages/music/adx.dart new file mode 100644 index 0000000..de6c8ba --- /dev/null +++ b/lib/pages/music/adx.dart @@ -0,0 +1,755 @@ +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:path_provider/path_provider.dart'; +import 'package:open_file/open_file.dart'; +import 'dart:io'; +import '../../../model/song_model.dart'; +import '../../../service/song_service.dart'; +import 'package:flutter/cupertino.dart'; + +import '../../tool/cacheImage.dart'; + +class AdxDownloadGridPage extends StatefulWidget { + const AdxDownloadGridPage({super.key}); + + @override + State createState() => _AdxDownloadGridPageState(); +} + +class _AdxDownloadGridPageState extends State with SingleTickerProviderStateMixin { + bool _isLoading = true; + String _error = ''; + List _songs = []; + List _displaySongs = []; + + // 记录正在下载的歌曲ID与类型,避免重复点击 + final Map _downloading = {}; + + // 筛选相关 + String _searchQuery = ''; + int? _filterLevelType; + Set _filterVersions = {}; + Set _filterGenres = {}; + 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 = {}; + + @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); + _loadSongs(); + } + + @override + void dispose() { + _animationController.dispose(); + _minLevelScrollController.dispose(); + _maxLevelScrollController.dispose(); + super.dispose(); + } + + Future _loadSongs() async { + setState(() => _isLoading = true); + try { + final songs = await SongService.getAllSongs(); + // 去重 + final map = {}; + for (var s in songs) { + map.putIfAbsent(s.id, () => s); + } + _songs = map.values.toList(); + _displaySongs = List.from(_songs); + + // 初始化筛选选项 + _availableVersions = songs.map((s) => s.from).where((v) => v.isNotEmpty).toSet(); + _availableGenres = songs.map((s) => s.genre).where((g) => g.isNotEmpty).toSet(); + + setState(() => _error = ''); + } catch (e) { + setState(() => _error = e.toString()); + } finally { + setState(() => _isLoading = false); + } + } + + // 核心:下载 ADX + Future _downloadAdx(SongModel song, String type) async { + final key = "${song.id}_$type"; + if (_downloading[key] == true) return; + + setState(() => _downloading[key] = true); + try { + int id = type == 'SD' ? song.id : song.id + 10000; + final url = "https://cdn.godserver.cn/resource/static/adx/$id.adx"; + + final res = await http.get(Uri.parse(url)); + if (res.statusCode != 200) throw Exception("文件不存在"); + + final dir = await getApplicationDocumentsDirectory(); + final safeTitle = song.title?.replaceAll(RegExp(r'[^\w\s\u4e00-\u9fa5]'), '_') ?? "song"; + final file = File("${dir.path}/${safeTitle}_$type.adx"); + await file.writeAsBytes(res.bodyBytes); + + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("✅ ${song.title} ($type) 下载完成")), + ); + await OpenFile.open(file.path); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("❌ 下载失败:$e")), + ); + } finally { + if (mounted) setState(() => _downloading[key] = false); + } + } + + // 筛选应用 + void _applyFilters() { + final q = _normalizeSearch(_searchQuery); + _displaySongs = _songs.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; + if (!match) return false; + } + + // 版本过滤 + if (_filterVersions.isNotEmpty && !_filterVersions.contains(song.from)) return false; + + // 流派过滤 + if (_filterGenres.isNotEmpty && !_filterGenres.contains(song.genre)) 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(); + } + + // 获取所有难度 + 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}); + } + } + return all; + } + + // 重置筛选 + void _resetFilters() { + setState(() { + _searchQuery = ''; + _filterLevelType = null; + _filterVersions.clear(); + _filterGenres.clear(); + _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: [], + ), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ); + }, + ); + } + + // 多选筛选构建 + Widget _buildMultiSelectChip({ + required String label, + required Set selectedValues, + required Set allOptions, + required Function(Set) onSelectionChanged, + }) { + return InkWell( + onTap: () { + _showMultiSelectDialog( + context, + title: label, + allOptions: allOptions.toList()..sort(), + selectedValues: selectedValues, + onConfirm: (newSelection) { + setState(() { + onSelectionChanged(newSelection); + }); + _applyFilters(); + }, + ); + }, + 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(label, style: const TextStyle(fontWeight: FontWeight.bold)), + Flexible( + child: Text( + selectedValues.isEmpty ? "全部" : "已选 ${selectedValues.length}", + style: TextStyle(color: Colors.grey[600], fontSize: 12), + overflow: TextOverflow.ellipsis, + ), + ), + const Icon(Icons.arrow_drop_down, color: Colors.grey), + ], + ), + ), + ); + } + + // 多选对话框 + void _showMultiSelectDialog( + BuildContext context, { + required String title, + required List allOptions, + required Set selectedValues, + required Function(Set) onConfirm, + }) { + final tempSelection = Set.from(selectedValues); + + showDialog( + context: context, + builder: (ctx) { + return StatefulBuilder( + builder: (context, setDialogState) { + return AlertDialog( + title: Text(title), + content: SizedBox( + width: double.maxFinite, + child: ListView.builder( + shrinkWrap: true, + itemCount: allOptions.length, + itemBuilder: (context, index) { + final option = allOptions[index]; + final isSelected = tempSelection.contains(option); + return CheckboxListTile( + title: Text(option), + value: isSelected, + onChanged: (bool? value) { + setDialogState(() { + if (value == true) { + tempSelection.add(option); + } else { + tempSelection.remove(option); + } + }); + }, + controlAffinity: ListTileControlAffinity.leading, + dense: true, + ); + }, + ), + ), + actions: [ + TextButton( + onPressed: () { + setDialogState(() => tempSelection.clear()); + }, + child: const Text("清空"), + ), + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text("取消"), + ), + ElevatedButton( + onPressed: () { + onConfirm(tempSelection); + Navigator.pop(ctx); + }, + child: const Text("确定"), + ), + ], + ); + }, + ); + }, + ); + } + + // 下拉框 + 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 || + _filterVersions.isNotEmpty || + _filterGenres.isNotEmpty || + _selectedMinLevel != null || + _selectedMaxLevel != null; + + String _coverUrl(int id) { + final d = id % 10000; + if (id >= 16000 && id <= 20000) { + final s = d.toString().padLeft(6, '0'); + return "https://u.mai2.link/jacket/UI_Jacket_$s.jpg"; + } else { + return "https://cdn.godserver.cn/resource/static/mai/cover/$d.png"; + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text("ADX 谱面下载库"), + centerTitle: true, + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + onPressed: _loadSongs, + ), + ], + ), + 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: _buildMultiSelectChip( + label: "版本", + selectedValues: _filterVersions, + allOptions: _availableVersions, + onSelectionChanged: (newSet) { + _filterVersions = newSet; + }, + ), + ), + const SizedBox(width: 8), + Expanded( + child: _buildMultiSelectChip( + label: "流派", + selectedValues: _filterGenres, + allOptions: _availableGenres, + onSelectionChanged: (newSet) { + _filterGenres = newSet; + }, + ), + ), + ], + ), + 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), + ], + ), + ), + ), + ], + ), + ), + ), + ], + ), + ); + } + + Widget _buildBody() { + if (_isLoading) { + return const Center(child: CircularProgressIndicator()); + } + if (_error.isNotEmpty) { + return Center(child: Text(_error, style: const TextStyle(color: Colors.red))); + } + if (_displaySongs.isEmpty) { + return const Center(child: Text("暂无匹配歌曲")); + } + + return GridView.builder( + padding: const EdgeInsets.all(12), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + crossAxisSpacing: 12, + mainAxisSpacing: 12, + childAspectRatio: 0.75, + ), + itemCount: _displaySongs.length, + itemBuilder: (ctx, i) => _songCard(_displaySongs[i]), + ); + } + + Widget _songCard(SongModel song) { + final cover = _coverUrl(song.id); + final hasSD = song.sd?.isNotEmpty ?? false; + final hasDX = song.dx?.isNotEmpty ?? false; + final keySD = "${song.id}_SD"; + final keyDX = "${song.id}_DX"; + + return Card( + elevation: 4, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + flex: 3, + child: ClipRRect( + borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), + child: CacheImage.network( + cover, + errorBuilder: (_, __, ___) => Container( + color: Colors.grey[200], + child: const Icon(Icons.music_note, size: 40), + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "[${song.id}] ${song.title}", + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + song.artist ?? "Unknown", + style: TextStyle(fontSize: 11, color: Colors.grey[600]), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + child: Row( + children: [ + if (hasSD) + Expanded( + child: _downloadBtn( + text: "SD", + color: Colors.green, + loading: _downloading[keySD] ?? false, + onTap: () => _downloadAdx(song, 'SD'), + ), + ), + if (hasSD && hasDX) const SizedBox(width: 6), + if (hasDX) + Expanded( + child: _downloadBtn( + text: "DX", + color: Colors.purple, + loading: _downloading[keyDX] ?? false, + onTap: () => _downloadAdx(song, 'DX'), + ), + ), + ], + ), + ), + const SizedBox(height: 6), + ], + ), + ); + } + + Widget _downloadBtn({ + required String text, + required Color color, + required bool loading, + required VoidCallback onTap, + }) { + return ElevatedButton( + onPressed: loading ? null : onTap, + style: ElevatedButton.styleFrom( + backgroundColor: color, + padding: const EdgeInsets.symmetric(vertical: 10), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + child: loading + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2, + ), + ) + : Text( + text, + style: const TextStyle(color: Colors.white, fontSize: 12), + ), + ); + } +} \ No newline at end of file diff --git a/lib/pages/music/music_page.dart b/lib/pages/music/music_page.dart index 1ce08ea..00bf3b5 100644 --- a/lib/pages/music/music_page.dart +++ b/lib/pages/music/music_page.dart @@ -5,6 +5,7 @@ import '../../../model/song_model.dart'; import '../../../providers/user_provider.dart'; import '../../../service/song_service.dart'; import '../../../service/user_service.dart'; +import '../../tool/cacheImage.dart'; class MusicPage extends StatefulWidget { const MusicPage({super.key}); @@ -731,7 +732,7 @@ class _MusicPageState extends State with SingleTickerProviderStateMix children: [ ClipRRect( borderRadius: BorderRadius.circular(2), - child: Image.network( + child: CacheImage.network( cover, width: 100, height: 100, diff --git a/lib/pages/music/score_single.dart b/lib/pages/music/score_single.dart index 882f477..9c08a72 100644 --- a/lib/pages/music/score_single.dart +++ b/lib/pages/music/score_single.dart @@ -10,6 +10,7 @@ import '../../../providers/user_provider.dart'; import '../../../service/song_service.dart'; import '../../../service/user_service.dart'; import '../../../widgets/score_progress_chart.dart'; +import '../../tool/cacheImage.dart'; import '../../tool/gradientText.dart'; class SongDetailPage extends StatefulWidget { @@ -364,7 +365,7 @@ class _SongDetailPageState extends State { children: [ ClipRRect( borderRadius: BorderRadius.circular(10), - child: Image.network( + child: CacheImage.network( coverUrl, width: 90, height: 90, diff --git a/lib/pages/score/score_page.dart b/lib/pages/score/score_page.dart index 6ff07d1..89b17d4 100644 --- a/lib/pages/score/score_page.dart +++ b/lib/pages/score/score_page.dart @@ -9,6 +9,8 @@ import 'package:share_plus/share_plus.dart'; import 'package:dio/dio.dart'; import 'package:path_provider/path_provider.dart'; +import '../../tool/cacheImage.dart'; + class ScorePage extends StatefulWidget { const ScorePage({Key? key}) : super(key: key); @@ -150,6 +152,7 @@ class _ScorePageState extends State with SingleTickerProviderStateMix } Future _loadData({bool isInitialLoad = false}) async { + // 首次加载才显示加载圈 if (isInitialLoad) { setState(() { _isLoading = true; }); } else { @@ -158,10 +161,30 @@ class _ScorePageState extends State with SingleTickerProviderStateMix try { final userProvider = UserProvider.instance; - final token = userProvider.token; - if (token == null) throw "未登录,请先登录"; + // ============================================== + // 【关键优化】等待 UserProvider 真正初始化完成 + // 避免刚进页面就判断 token 导致的“太快报错” + // ============================================== + await userProvider.waitInit(); // 你需要在 UserProvider 里加这个方法(我下面会给你代码) + + // 现在再获取 token,此时状态已经稳定 + final token = userProvider.token; + + if (token == null || token.isEmpty) { + debugPrint("ℹ️ 用户未登录,清空用户数据"); + // setState(() { + // _userMusicList = []; + // _errorMessage = ''; + // }); + return; // 温和退出,不弹错误 + } + + debugPrint("✅ 用户已登录,开始加载数据"); + + // 只有首次加载 / 数据为空时才拉全量歌曲列表 if (_allSongs.isEmpty || isInitialLoad) { + debugPrint("ℹ️ 加载全量歌曲列表"); final songs = await SongService.getAllSongs(); _allSongs = songs; _songMap = {for (var song in songs) song.id: song}; @@ -174,6 +197,7 @@ class _ScorePageState extends State with SingleTickerProviderStateMix if (segaId == null || segaId.isEmpty) { throw "请选择一个有效的 Sega ID"; } + debugPrint("ℹ️ 加载 Sega 评分数据"); final rawData = await UserService.getSegaRatingData(token, segaId); final segaId2chartlist = rawData['segaId2chartlist'] as Map?; @@ -204,7 +228,8 @@ class _ScorePageState extends State with SingleTickerProviderStateMix }).toList(); } else { - final scoreData = await SongService.getUserAllScores(token,name: _currentCnUserName); + debugPrint("ℹ️ 加载用户评分数据"); + final scoreData = await SongService.getUserAllScores(token, name: _currentCnUserName); if (scoreData.containsKey('userScoreAll_')) { _userMusicList = scoreData['userScoreAll_']['userMusicList'] ?? []; @@ -216,6 +241,7 @@ class _ScorePageState extends State with SingleTickerProviderStateMix } setState(() { _errorMessage = ''; }); + debugPrint("✅ 数据加载完成"); } catch (e) { debugPrint("❌ Load Data Error: $e"); @@ -237,7 +263,6 @@ class _ScorePageState extends State with SingleTickerProviderStateMix } } } - List get _filteredMusicList { bool isNoFilter = _searchQuery.isEmpty && _filterLevelType == null && @@ -421,6 +446,16 @@ class _ScorePageState extends State with SingleTickerProviderStateMix final segaCards = userProvider.availableSegaCards; final currentSegaId = userProvider.selectedSegaId; + // 安全处理:确保 value 一定存在于 items 中 + String? safeValue; + if (isSegaMode) { + // 检查 segaId 是否真的在列表里 + safeValue = segaCards.any((card) => card.segaId == currentSegaId) ? currentSegaId : null; + } else { + // 检查用户名是否真的在列表里 + safeValue = (currentCnUserName != null && cnUserNames.contains(currentCnUserName)) ? currentCnUserName : null; + } + return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), color: Theme.of(context).cardColor, @@ -452,9 +487,7 @@ class _ScorePageState extends State with SingleTickerProviderStateMix flex: 1, child: DropdownButtonHideUnderline( child: DropdownButtonFormField( - value: isSegaMode - ? currentSegaId - : (currentCnUserName != null && currentCnUserName.isNotEmpty ? currentCnUserName : null), + value: safeValue, // <--- 这里用安全值,修复报错 decoration: InputDecoration( hintText: isSegaMode ? "选择卡片" : "选择账号", border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), @@ -1110,7 +1143,7 @@ class _ScorePageState extends State with SingleTickerProviderStateMix borderRadius: BorderRadius.circular(4), child: Stack( children: [ - Image.network( + CacheImage.network( coverUrl, width: 50, height: 50, diff --git a/lib/pages/setting_page.dart b/lib/pages/setting_page.dart index 8a4a70e..362f1eb 100644 --- a/lib/pages/setting_page.dart +++ b/lib/pages/setting_page.dart @@ -1,31 +1,320 @@ +import 'dart:io'; +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +import 'package:path_provider/path_provider.dart'; +import 'dart:math'; +import 'package:webview_flutter/webview_flutter.dart'; // 【你要的导入】 + +import '../providers/user_provider.dart'; class SettingPage extends StatelessWidget { const SettingPage({super.key}); @override Widget build(BuildContext context) { + final isDarkMode = Theme.of(context).brightness == Brightness.dark; + return Scaffold( - // 注意:这里不需要 bottomNavigationBar 了,因为外层已经有了 - backgroundColor: Colors.transparent, // 关键:背景透明,透出玻璃效果 - body: CustomScrollView( - slivers: [ - SliverAppBar.large( - title: const Text('首页'), - floating: true, - backgroundColor: Colors.blueAccent.withOpacity(0.8), + backgroundColor: isDarkMode ? Colors.black : Colors.grey[50], + appBar: AppBar( + title: const Text( + "设置", + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w600, ), - SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) => ListTile( - title: Text('列表项 $index'), - leading: const Icon(Icons.article), - ), - childCount: 20, + ), + centerTitle: true, + backgroundColor: isDarkMode ? Colors.black : Colors.white, + elevation: 0, + foregroundColor: isDarkMode ? Colors.white : Colors.black87, + ), + body: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), + child: Column( + children: [ + const SizedBox(height: 10), + + const _SettingItem( + title: "缓存管理", + icon: Icons.storage_rounded, + hasSwitch: false, + showTrailingText: true, ), + const SizedBox(height: 12), + + const _SettingItem( + title: "退出登录/清除账号", + icon: Icons.logout_rounded, + hasSwitch: false, + isLogout: true, + ), + const SizedBox(height: 12), + + const _SettingItem( + title: "隐私政策", + icon: Icons.privacy_tip_rounded, + hasSwitch: false, + ), + const SizedBox(height: 12), + + const _SettingItem( + title: "关于我们", + icon: Icons.info_outline_rounded, + hasSwitch: false, + ), + const SizedBox(height: 30), + ], + ), + ), + ); + } +} + +class _SettingItem extends StatefulWidget { + const _SettingItem({ + required this.title, + required this.icon, + required this.hasSwitch, + this.showTrailingText = false, + this.isLogout = false, + }); + + final String title; + final IconData icon; + final bool hasSwitch; + final bool showTrailingText; + final bool isLogout; + + @override + State<_SettingItem> createState() => _SettingItemState(); +} + +class _SettingItemState extends State<_SettingItem> { + String cacheSize = "计算中..."; + + @override + void initState() { + super.initState(); + if (widget.showTrailingText) { + _getCacheSize(); + } + } + + Future _getCacheSize() async { + try { + final cacheDir = await getTemporaryDirectory(); + final imageCacheDir = Directory("${cacheDir.path}/image_cache"); + + if (await imageCacheDir.exists()) { + int totalBytes = 0; + await for (var file in imageCacheDir.list(recursive: true)) { + if (file is File) { + totalBytes += await file.length(); + } + } + cacheSize = _formatBytes(totalBytes); + } else { + cacheSize = "0 B"; + } + } catch (e) { + cacheSize = "获取失败"; + } + if (mounted) setState(() {}); + } + + Future _clearCache() async { + try { + final cacheDir = await getTemporaryDirectory(); + final imageCacheDir = Directory("${cacheDir.path}/image_cache"); + + if (await imageCacheDir.exists()) { + await imageCacheDir.delete(recursive: true); + } + + setState(() { + cacheSize = "0 B"; + }); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text("✅ 缓存已清空")), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text("❌ 清空缓存失败")), + ); + } + } + } + + void _showClearCacheConfirm() { + showCupertinoDialog( + context: context, + builder: (context) => CupertinoAlertDialog( + title: const Text("确定清空缓存?"), + content: const Text("清空后将重新加载图片等数据,无法恢复"), + actions: [ + CupertinoDialogAction( + child: const Text("取消"), + onPressed: () => Navigator.pop(context), + ), + CupertinoDialogAction( + child: const Text("确定清空"), + onPressed: () async { + Navigator.pop(context); + await _clearCache(); + }, ), ], ), ); } + + Future _deleteAccount() async { + showCupertinoDialog( + context: context, + builder: (context) => CupertinoAlertDialog( + title: const Text("确定退出登录?"), + content: const Text("将清除本地账号信息,需重新登录"), + actions: [ + CupertinoDialogAction( + child: const Text("取消"), + onPressed: () => Navigator.pop(context), + ), + CupertinoDialogAction( + child: const Text("确定退出"), + onPressed: () async { + Navigator.pop(context); + await UserProvider.instance.logout(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text("✅ 已退出登录,账号已清除")), + ); + } + }, + ), + ], + ), + ); + } + + // ================== 打开 WebView 页面 ================== + void _openWebView(String title, String url) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => Scaffold( + appBar: AppBar(title: Text(title)), + body: WebViewWidget( + controller: WebViewController() + ..setJavaScriptMode(JavaScriptMode.unrestricted) + ..loadRequest(Uri.parse(url)), + ), + ), + ), + ); + } + + String _formatBytes(int bytes) { + if (bytes <= 0) return "0 B"; + const suffixes = ["B", "KB", "MB", "GB"]; + var i = (log(bytes) / log(1024)).floor(); + return '${(bytes / pow(1024, i)).toStringAsFixed(2)} ${suffixes[i]}'; + } + + @override + Widget build(BuildContext context) { + final isDarkMode = Theme.of(context).brightness == Brightness.dark; + + return GestureDetector( + onTap: () { + if (widget.showTrailingText) { + _showClearCacheConfirm(); + } else if (widget.isLogout) { + _deleteAccount(); + } else if (widget.title == "隐私政策") { + _openWebView("隐私政策", "https://union.godserver.cn/document"); + } else if (widget.title == "关于我们") { + _openWebView("关于我们", "https://union.godserver.cn/thank"); + } + }, + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 16), + decoration: BoxDecoration( + color: isDarkMode ? Colors.grey[900] : Colors.white, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Icon( + widget.icon, + color: isDarkMode ? Colors.white : Colors.black87, + size: 22, + ), + const SizedBox(width: 14), + Text( + widget.title, + style: TextStyle( + color: isDarkMode ? Colors.white : Colors.black87, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + widget.hasSwitch + ? const _SimpleSwitch() + : widget.showTrailingText + ? Text( + cacheSize, + style: TextStyle( + color: isDarkMode ? Colors.white54 : Colors.black54, + fontSize: 14, + ), + ) + : widget.isLogout + ? const SizedBox() + : Icon( + Icons.arrow_forward_ios_rounded, + color: isDarkMode ? Colors.white54 : Colors.black54, + size: 16, + ), + ], + ), + ), + ); + } +} + +class _SimpleSwitch extends StatefulWidget { + const _SimpleSwitch(); + + @override + State<_SimpleSwitch> createState() => _SimpleSwitchState(); +} + +class _SimpleSwitchState extends State<_SimpleSwitch> { + bool isEnabled = true; + + @override + Widget build(BuildContext context) { + final isDarkMode = Theme.of(context).brightness == Brightness.dark; + + return CupertinoSwitch( + value: isEnabled, + onChanged: (val) { + setState(() => isEnabled = val); + }, + activeColor: isDarkMode ? Colors.white : Colors.blueAccent, + trackColor: isDarkMode ? Colors.white38 : Colors.grey[300], + thumbColor: isDarkMode ? Colors.white : Colors.white, + ); + } } \ No newline at end of file diff --git a/lib/pages/user/userpage.dart b/lib/pages/user/userpage.dart index 65f5a5e..4150840 100644 --- a/lib/pages/user/userpage.dart +++ b/lib/pages/user/userpage.dart @@ -8,6 +8,7 @@ import 'package:dio/dio.dart'; import '../../model/user_model.dart'; import '../../providers/user_provider.dart'; import '../../service/sega_service.dart'; +import '../../tool/cacheImage.dart'; import 'login_page.dart'; class UserPage extends StatefulWidget { @@ -34,14 +35,15 @@ class _UserPageState extends State { Future _loadRadarData() async { final provider = Provider.of(context, listen: false); try { + await provider.waitInit(); // 你需要在 UserProvider 里加这个方法(我下面会给你代码) final data = await provider.fetchRadarData(provider.user?.id ?? 'default_id'); setState(() { _radarData = data; }); if(mounted){ - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text("雷达图数据加载成功 ✅")), - ); + // ScaffoldMessenger.of(context).showSnackBar( + // const SnackBar(content: Text("雷达图数据加载成功 ✅")), + // ); } } catch (e) { if(mounted){ @@ -993,7 +995,7 @@ class _UserPageState extends State { children: [ ClipRRect( borderRadius: BorderRadius.circular(8), - child: Image.network( + child: CacheImage.network( provider.avatarUrl, width: 80, height: 80, @@ -1089,7 +1091,7 @@ class _UserPageState extends State { children: [ ClipRRect( borderRadius: BorderRadius.circular(6), - child: Image.network( + child: CacheImage.network( "https://cdn.godserver.cn/resource/static/coll/Icon/UI_Icon_$iconId.png", width: 50, height: 50, @@ -1612,7 +1614,7 @@ class _UserPageState extends State { data: _radarData!.map((key, value) => MapEntry(key.toString(), double.tryParse(value.toString()) ?? 0.0) ), - maxValue: 1.3, + maxValue: 1.2, lineColor: Colors.grey.shade200, areaColor: Colors.pink.withOpacity(0.15), borderColor: Colors.pinkAccent, diff --git a/lib/providers/user_provider.dart b/lib/providers/user_provider.dart index ab0d1a1..d86307d 100644 --- a/lib/providers/user_provider.dart +++ b/lib/providers/user_provider.dart @@ -1,22 +1,33 @@ import 'dart:convert'; -import 'dart:typed_data'; // 确保导入,因为 uploadStegImage 需要 Uint8List +import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../model/user_model.dart'; import '../service/user_service.dart'; import '../tool/encryption_util.dart'; +import 'dart:async'; // 必须加这个 class UserProvider with ChangeNotifier { UserModel? _user; String? _token; List _sexTags = []; - // --- 成绩数据源相关状态 --- - String _scoreDataSource = 'cn'; // 'cn' or 'sega' - String? _selectedSegaId; // 选中的 SegaID + String _scoreDataSource = 'cn'; + String? _selectedSegaId; + String? _selectedCnUserName; - // --- 新增:国服多账号支持 --- - String? _selectedCnUserName; // 选中的国服用户名 (对应 userName2userId 的 key) + // ===================== 【新增:初始化等待控制】 ===================== + bool _isInitialized = false; + Completer? _initCompleter; + + // 外部等待初始化完成的方法 + Future waitInit() async { + if (_isInitialized) return; + if (_initCompleter == null) { + _initCompleter = Completer(); + } + await _initCompleter!.future; + } UserModel? get user => _user; String? get token => _token; @@ -30,7 +41,6 @@ class UserProvider with ChangeNotifier { List get availableSegaCards => _user?.segaCards ?? []; - // 获取可用的国服账号列表 List get availableCnUserNames { final map = _user?.userName2userId; if (map == null || map.isEmpty) return []; @@ -52,35 +62,38 @@ class UserProvider with ChangeNotifier { } Future initUser() async { - final prefs = await SharedPreferences.getInstance(); - _token = prefs.getString("token"); + try { + final prefs = await SharedPreferences.getInstance(); + _token = prefs.getString("token"); - // 读取偏好设置 - _scoreDataSource = prefs.getString("scoreDataSource") ?? 'cn'; - _selectedSegaId = prefs.getString("selectedSegaId"); - _selectedCnUserName = prefs.getString("selectedCnUserName"); + _scoreDataSource = prefs.getString("scoreDataSource") ?? 'cn'; + _selectedSegaId = prefs.getString("selectedSegaId"); + _selectedCnUserName = prefs.getString("selectedCnUserName"); - if (_token != null) { - try { - _user = await UserService.getUserInfo(_token!); - await fetchSexTags(); + if (_token != null) { + try { + _user = await UserService.getUserInfo(_token!); + await fetchSexTags(); - // 校验 SegaID 有效性 - if (_selectedSegaId != null && !availableSegaCards.any((c) => c.segaId == _selectedSegaId)) { - _selectedSegaId = null; - await prefs.remove("selectedSegaId"); + if (_selectedSegaId != null && !availableSegaCards.any((c) => c.segaId == _selectedSegaId)) { + _selectedSegaId = null; + await prefs.remove("selectedSegaId"); + } + + if (_selectedCnUserName != null && !availableCnUserNames.contains(_selectedCnUserName)) { + _selectedCnUserName = null; + await prefs.remove("selectedCnUserName"); + } + } catch (e) { + await logout(); } - - // 校验国服用户名有效性 - if (_selectedCnUserName != null && !availableCnUserNames.contains(_selectedCnUserName)) { - _selectedCnUserName = null; - await prefs.remove("selectedCnUserName"); - } - } catch (e) { - await logout(); } + } finally { + // ===================== 【关键:标记初始化完成】 ===================== + _isInitialized = true; + _initCompleter?.complete(); + notifyListeners(); } - notifyListeners(); } Future login(String username, String twoKeyCode) async { @@ -101,8 +114,6 @@ class UserProvider with ChangeNotifier { Future register(String username, String password, String inviter) async { await UserService.register(username, password, inviter); - // 注意:注册后通常不直接登录,或者根据业务需求决定。这里保持原逻辑。 - // await login(username, password); } Future logout() async { @@ -166,7 +177,6 @@ class UserProvider with ChangeNotifier { notifyListeners(); } - // --- 设置数据源 --- Future setScoreDataSource(String source) async { _scoreDataSource = source; final prefs = await SharedPreferences.getInstance(); @@ -174,7 +184,6 @@ class UserProvider with ChangeNotifier { notifyListeners(); } - // --- 设置选中的 SegaID --- Future setSelectedSegaId(String? segaId) async { _selectedSegaId = segaId; final prefs = await SharedPreferences.getInstance(); @@ -186,7 +195,6 @@ class UserProvider with ChangeNotifier { notifyListeners(); } - // --- 设置选中的国服用户名 --- Future setSelectedCnUserName(String? userName) async { _selectedCnUserName = userName; final prefs = await SharedPreferences.getInstance(); @@ -225,17 +233,11 @@ class UserProvider with ChangeNotifier { } } - // ================= 新增:上传隐写图片 ================= - - /// 上传带有隐写数据的图片 - /// [imageBytes] 原始图片的字节数据 (Uint8List) - /// [segaId] 要隐藏进图片的 SegaID (如果为 null,则使用当前选中的 _selectedSegaId) Future> uploadStegImage(Uint8List imageBytes, {String? segaId}) async { if (_token == null) { throw Exception("请先登录"); } - // 如果未指定 segaId,则使用当前选中的 final targetSegaId = segaId ?? _selectedSegaId; if (targetSegaId == null || targetSegaId.isEmpty) { diff --git a/lib/tool/cacheImage.dart b/lib/tool/cacheImage.dart new file mode 100644 index 0000000..2412e54 --- /dev/null +++ b/lib/tool/cacheImage.dart @@ -0,0 +1,194 @@ +import 'dart:io'; +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:dio/dio.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:crypto/crypto.dart'; +import 'package:path/path.dart' as p; + +/// 一个支持自动 Dio 缓存的 Image 组件 +/// 用法完全对齐 Image.network(url) +class CacheImage extends StatefulWidget { + final String url; + final double? width; + final double? height; + final BoxFit? fit; + final double scale; + final AlignmentGeometry alignment; + final ImageRepeat repeat; + final FilterQuality filterQuality; + final Color? color; + final BlendMode? colorBlendMode; + final ImageLoadingBuilder? loadingBuilder; + final ImageErrorWidgetBuilder? errorBuilder; + final bool matchTextDirection; + + // 使用位置参数接收 url,其余为可选命名参数 + const CacheImage.network( + this.url, { + Key? key, + this.width, + this.height, + this.fit, + this.scale = 1.0, + this.alignment = Alignment.center, + this.repeat = ImageRepeat.noRepeat, + this.filterQuality = FilterQuality.low, + this.color, + this.colorBlendMode, + this.loadingBuilder, + this.errorBuilder, + this.matchTextDirection = false, + }) : super(key: key); + + @override + State createState() => _CacheImageState(); +} + +class _CacheImageState extends State { + File? _localFile; + bool _isInit = false; + + /// 统一日志工具(支持发布/调试环境) + void _log(String message, {bool isError = false}) { + // 发布环境只打印错误日志,调试环境全部打印 + if (kReleaseMode) { + if (isError) { + print('[CacheImage-ERROR] $message'); + } + } else { + final tag = isError ? '[CacheImage-ERROR]' : '[CacheImage-INFO]'; + print('$tag $message'); + } + } + + @override + void initState() { + super.initState(); + _log('图片组件初始化,URL: ${widget.url}'); + _handleCache(); + } + + // 当外部传入的 url 发生变化时,重新触发缓存逻辑 + @override + void didUpdateWidget(CacheImage oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.url != widget.url) { + _log('URL 发生变化,重新加载缓存\n旧URL: ${oldWidget.url}\n新URL: ${widget.url}'); + _handleCache(); + } + } + + Future _handleCache() async { + try { + _log('开始处理图片缓存逻辑,URL: ${widget.url}'); + + // 1. 生成唯一文件名 (MD5) + final String fileName = md5.convert(utf8.encode(widget.url)).toString(); + _log('生成文件MD5: $fileName'); + + // 获取临时目录 (建议缓存放在临时目录,由系统根据空间决定是否清理) + final Directory cacheDir = await getTemporaryDirectory(); + final File file = File(p.join(cacheDir.path, 'image_cache', fileName)); + _log('本地缓存路径: ${file.path}'); + + // 2. 检查本地是否存在 + if (await file.exists()) { + _log('✅ 本地缓存已存在,直接加载本地文件'); + if (mounted) { + setState(() { + _localFile = file; + _isInit = true; + }); + } + return; + } + + // 3. 本地不存在,使用 Dio 下载 + _log('ℹ️ 本地无缓存,开始从网络下载图片'); + final dio = Dio(); + final response = await dio.get( + widget.url, + options: Options(responseType: ResponseType.bytes), + ); + _log('✅ 图片下载完成,数据大小: ${response.data.length} bytes'); + + // 4. 尝试保存到本地 (Quiet Mode: 失败仅打印,不抛出异常) + try { + await file.parent.create(recursive: true); + await file.writeAsBytes(response.data); + _log('✅ 图片成功保存到本地缓存'); + if (mounted) { + setState(() { + _localFile = file; + }); + } + } catch (e) { + _log('❌ 本地保存失败 (不影响显示): $e', isError: true); + } + } catch (e) { + _log('❌ 下载或逻辑处理失败: $e', isError: true); + } finally { + if (mounted) { + setState(() => _isInit = true); + } + _log('🏁 图片缓存逻辑执行完成'); + } + } + + @override + Widget build(BuildContext context) { + // 如果本地文件已就绪,直接加载本地文件 + if (_localFile != null) { + _log('使用本地缓存图片渲染'); + return Image.file( + _localFile!, + key: widget.key, + width: widget.width, + height: widget.height, + fit: widget.fit, + alignment: widget.alignment, + repeat: widget.repeat, + filterQuality: widget.filterQuality, + color: widget.color, + colorBlendMode: widget.colorBlendMode, + matchTextDirection: widget.matchTextDirection, + // 如果文件读取过程中出错,尝试回退到网络或报错组件 + errorBuilder: widget.errorBuilder ?? (context, error, stack) { + _log('❌ 本地图片加载失败,回退到网络加载', isError: true); + return _buildNetworkImage(); + }, + ); + } + + // 默认或下载中,渲染网络图片 + _log('使用网络图片渲染(加载中/无缓存)'); + return _buildNetworkImage(); + } + + Widget _buildNetworkImage() { + return Image.network( + widget.url, + key: widget.key, + width: widget.width, + height: widget.height, + fit: widget.fit, + scale: widget.scale, + alignment: widget.alignment, + repeat: widget.repeat, + filterQuality: widget.filterQuality, + color: widget.color, + colorBlendMode: widget.colorBlendMode, + matchTextDirection: widget.matchTextDirection, + loadingBuilder: widget.loadingBuilder, + errorBuilder: (context, error, stack) { + _log('❌ 网络图片加载失败: $error', isError: true); + if (widget.errorBuilder != null) { + return widget.errorBuilder!(context, error, stack); + } + return const SizedBox(); + }, + ); + } +} \ No newline at end of file diff --git a/macos/Podfile.lock b/macos/Podfile.lock index 878e86b..e24f644 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -1,4 +1,6 @@ PODS: + - file_selector_macos (0.0.1): + - FlutterMacOS - FlutterMacOS (1.0.0) - geolocator_apple (1.2.0): - Flutter @@ -19,6 +21,7 @@ PODS: - FlutterMacOS DEPENDENCIES: + - file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`) - FlutterMacOS (from `Flutter/ephemeral`) - geolocator_apple (from `Flutter/ephemeral/.symlinks/plugins/geolocator_apple/darwin`) - open_file_mac (from `Flutter/ephemeral/.symlinks/plugins/open_file_mac/macos`) @@ -29,6 +32,8 @@ DEPENDENCIES: - webview_flutter_wkwebview (from `Flutter/ephemeral/.symlinks/plugins/webview_flutter_wkwebview/darwin`) EXTERNAL SOURCES: + file_selector_macos: + :path: Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos FlutterMacOS: :path: Flutter/ephemeral geolocator_apple: @@ -47,6 +52,7 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/webview_flutter_wkwebview/darwin SPEC CHECKSUMS: + file_selector_macos: 9e9e068e90ebee155097d00e89ae91edb2374db7 FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e open_file_mac: 76f06c8597551249bdb5e8fd8827a98eae0f4585