ver1.00.00
update
This commit is contained in:
@@ -44,5 +44,9 @@
|
||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||
<data android:mimeType="text/plain"/>
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<data android:scheme="mqq" />
|
||||
</intent>
|
||||
</queries>
|
||||
</manifest>
|
||||
|
||||
@@ -73,5 +73,10 @@
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>LSApplicationQueriesSchemes</key>
|
||||
<array>
|
||||
<string>mqq</string>
|
||||
</array>
|
||||
</dict>
|
||||
|
||||
</plist>
|
||||
@@ -8,7 +8,6 @@ void main() {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
HardwareKeyboard.instance.clearState();
|
||||
|
||||
// 🔥 不 await!直接取实例
|
||||
final userProvider = UserProvider.instance;
|
||||
|
||||
// 🔥 后台异步初始化,不卡界面
|
||||
|
||||
@@ -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) {
|
||||
|
||||
755
lib/pages/music/adx.dart
Normal file
755
lib/pages/music/adx.dart
Normal file
@@ -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<AdxDownloadGridPage> createState() => _AdxDownloadGridPageState();
|
||||
}
|
||||
|
||||
class _AdxDownloadGridPageState extends State<AdxDownloadGridPage> with SingleTickerProviderStateMixin {
|
||||
bool _isLoading = true;
|
||||
String _error = '';
|
||||
List<SongModel> _songs = [];
|
||||
List<SongModel> _displaySongs = [];
|
||||
|
||||
// 记录正在下载的歌曲ID与类型,避免重复点击
|
||||
final Map<String, bool> _downloading = {};
|
||||
|
||||
// 筛选相关
|
||||
String _searchQuery = '';
|
||||
int? _filterLevelType;
|
||||
Set<String> _filterVersions = {};
|
||||
Set<String> _filterGenres = {};
|
||||
double? _selectedMinLevel;
|
||||
double? _selectedMaxLevel;
|
||||
bool _isAdvancedFilterExpanded = false;
|
||||
late AnimationController _animationController;
|
||||
late Animation<double> _animation;
|
||||
|
||||
static const List<double> _levelOptions = [
|
||||
1.0, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0, 4.5, 5.0, 5.5,
|
||||
6.0, 6.5, 7.0, 7.5, 8.0, 8.5, 9.0, 9.5, 10.0, 10.5,
|
||||
11.0, 11.5, 12.0, 12.5,
|
||||
13.0, 13.1, 13.2, 13.3, 13.4, 13.5, 13.6, 13.7, 13.8, 13.9,
|
||||
14.0, 14.1, 14.2, 14.3, 14.4, 14.5, 14.6, 14.7, 14.8, 14.9,
|
||||
15.0,
|
||||
];
|
||||
|
||||
late FixedExtentScrollController _minLevelScrollController;
|
||||
late FixedExtentScrollController _maxLevelScrollController;
|
||||
Set<String> _availableVersions = {};
|
||||
Set<String> _availableGenres = {};
|
||||
|
||||
@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<void> _loadSongs() async {
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
final songs = await SongService.getAllSongs();
|
||||
// 去重
|
||||
final map = <int, SongModel>{};
|
||||
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<void> _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<Map<String, dynamic>> _getAllDifficultiesWithType(SongModel song) {
|
||||
List<Map<String, dynamic>> 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<String> selectedValues,
|
||||
required Set<String> allOptions,
|
||||
required Function(Set<String>) 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<String> allOptions,
|
||||
required Set<String> selectedValues,
|
||||
required Function(Set<String>) onConfirm,
|
||||
}) {
|
||||
final tempSelection = Set<String>.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<DropdownMenuItem> 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<MusicPage> with SingleTickerProviderStateMix
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
child: Image.network(
|
||||
child: CacheImage.network(
|
||||
cover,
|
||||
width: 100,
|
||||
height: 100,
|
||||
|
||||
@@ -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<SongDetailPage> {
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: Image.network(
|
||||
child: CacheImage.network(
|
||||
coverUrl,
|
||||
width: 90,
|
||||
height: 90,
|
||||
|
||||
@@ -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<ScorePage> with SingleTickerProviderStateMix
|
||||
}
|
||||
|
||||
Future<void> _loadData({bool isInitialLoad = false}) async {
|
||||
// 首次加载才显示加载圈
|
||||
if (isInitialLoad) {
|
||||
setState(() { _isLoading = true; });
|
||||
} else {
|
||||
@@ -158,10 +161,30 @@ class _ScorePageState extends State<ScorePage> 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<ScorePage> 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<String, dynamic>?;
|
||||
@@ -204,7 +228,8 @@ class _ScorePageState extends State<ScorePage> 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<ScorePage> with SingleTickerProviderStateMix
|
||||
}
|
||||
|
||||
setState(() { _errorMessage = ''; });
|
||||
debugPrint("✅ 数据加载完成");
|
||||
|
||||
} catch (e) {
|
||||
debugPrint("❌ Load Data Error: $e");
|
||||
@@ -237,7 +263,6 @@ class _ScorePageState extends State<ScorePage> with SingleTickerProviderStateMix
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
List<dynamic> get _filteredMusicList {
|
||||
bool isNoFilter = _searchQuery.isEmpty &&
|
||||
_filterLevelType == null &&
|
||||
@@ -421,6 +446,16 @@ class _ScorePageState extends State<ScorePage> 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<ScorePage> with SingleTickerProviderStateMix
|
||||
flex: 1,
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButtonFormField<String>(
|
||||
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<ScorePage> with SingleTickerProviderStateMix
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Stack(
|
||||
children: [
|
||||
Image.network(
|
||||
CacheImage.network(
|
||||
coverUrl,
|
||||
width: 50,
|
||||
height: 50,
|
||||
|
||||
@@ -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<void> _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<void> _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<void> _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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<UserPage> {
|
||||
Future<void> _loadRadarData() async {
|
||||
final provider = Provider.of<UserProvider>(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<UserPage> {
|
||||
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<UserPage> {
|
||||
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<UserPage> {
|
||||
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,
|
||||
|
||||
@@ -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<String> _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<void>? _initCompleter;
|
||||
|
||||
// 外部等待初始化完成的方法
|
||||
Future<void> waitInit() async {
|
||||
if (_isInitialized) return;
|
||||
if (_initCompleter == null) {
|
||||
_initCompleter = Completer<void>();
|
||||
}
|
||||
await _initCompleter!.future;
|
||||
}
|
||||
|
||||
UserModel? get user => _user;
|
||||
String? get token => _token;
|
||||
@@ -30,7 +41,6 @@ class UserProvider with ChangeNotifier {
|
||||
|
||||
List<SegaCard> get availableSegaCards => _user?.segaCards ?? [];
|
||||
|
||||
// 获取可用的国服账号列表
|
||||
List<String> get availableCnUserNames {
|
||||
final map = _user?.userName2userId;
|
||||
if (map == null || map.isEmpty) return [];
|
||||
@@ -52,35 +62,38 @@ class UserProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
Future<void> 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<void> login(String username, String twoKeyCode) async {
|
||||
@@ -101,8 +114,6 @@ class UserProvider with ChangeNotifier {
|
||||
|
||||
Future<void> register(String username, String password, String inviter) async {
|
||||
await UserService.register(username, password, inviter);
|
||||
// 注意:注册后通常不直接登录,或者根据业务需求决定。这里保持原逻辑。
|
||||
// await login(username, password);
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
@@ -166,7 +177,6 @@ class UserProvider with ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// --- 设置数据源 ---
|
||||
Future<void> setScoreDataSource(String source) async {
|
||||
_scoreDataSource = source;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
@@ -174,7 +184,6 @@ class UserProvider with ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// --- 设置选中的 SegaID ---
|
||||
Future<void> setSelectedSegaId(String? segaId) async {
|
||||
_selectedSegaId = segaId;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
@@ -186,7 +195,6 @@ class UserProvider with ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// --- 设置选中的国服用户名 ---
|
||||
Future<void> 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<Map<String, dynamic>> uploadStegImage(Uint8List imageBytes, {String? segaId}) async {
|
||||
if (_token == null) {
|
||||
throw Exception("请先登录");
|
||||
}
|
||||
|
||||
// 如果未指定 segaId,则使用当前选中的
|
||||
final targetSegaId = segaId ?? _selectedSegaId;
|
||||
|
||||
if (targetSegaId == null || targetSegaId.isEmpty) {
|
||||
|
||||
194
lib/tool/cacheImage.dart
Normal file
194
lib/tool/cacheImage.dart
Normal file
@@ -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<CacheImage> createState() => _CacheImageState();
|
||||
}
|
||||
|
||||
class _CacheImageState extends State<CacheImage> {
|
||||
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<void> _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();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user