ver1.00.00
update
This commit is contained in:
@@ -44,5 +44,9 @@
|
|||||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||||
<data android:mimeType="text/plain"/>
|
<data android:mimeType="text/plain"/>
|
||||||
</intent>
|
</intent>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<data android:scheme="mqq" />
|
||||||
|
</intent>
|
||||||
</queries>
|
</queries>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -73,5 +73,10 @@
|
|||||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
</array>
|
</array>
|
||||||
|
<key>LSApplicationQueriesSchemes</key>
|
||||||
|
<array>
|
||||||
|
<string>mqq</string>
|
||||||
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
|
|
||||||
</plist>
|
</plist>
|
||||||
@@ -8,7 +8,6 @@ void main() {
|
|||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
HardwareKeyboard.instance.clearState();
|
HardwareKeyboard.instance.clearState();
|
||||||
|
|
||||||
// 🔥 不 await!直接取实例
|
|
||||||
final userProvider = UserProvider.instance;
|
final userProvider = UserProvider.instance;
|
||||||
|
|
||||||
// 🔥 后台异步初始化,不卡界面
|
// 🔥 后台异步初始化,不卡界面
|
||||||
|
|||||||
@@ -2,13 +2,17 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:unionapp/pages/music/music_page.dart';
|
import 'package:unionapp/pages/music/music_page.dart';
|
||||||
import '../../service/recommendation_helper.dart';
|
import '../../service/recommendation_helper.dart';
|
||||||
import '../../service/song_service.dart';
|
import '../../service/song_service.dart';
|
||||||
|
import '../../tool/cacheImage.dart';
|
||||||
import '../../tool/gradientText.dart';
|
import '../../tool/gradientText.dart';
|
||||||
|
import '../music/adx.dart';
|
||||||
|
import '../music/score_single.dart';
|
||||||
import '../score/updateScorePage.dart';
|
import '../score/updateScorePage.dart';
|
||||||
import '../user/userpage.dart';
|
import '../user/userpage.dart';
|
||||||
import '../scorelist.dart';
|
import '../scorelist.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../../providers/user_provider.dart';
|
import '../../providers/user_provider.dart';
|
||||||
import '../../model/song_model.dart';
|
import '../../model/song_model.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
class HomePage extends StatelessWidget {
|
class HomePage extends StatelessWidget {
|
||||||
final Function(int)? onSwitchTab;
|
final Function(int)? onSwitchTab;
|
||||||
@@ -177,7 +181,7 @@ class HomePage extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
// 左侧:智能推荐乐曲(占 6 份宽度)
|
// 左侧:智能推荐乐曲(占 6 份宽度)
|
||||||
Expanded(
|
Expanded(
|
||||||
flex: 6,
|
flex: 7,
|
||||||
child: const _RecommendedSongsSection(),
|
child: const _RecommendedSongsSection(),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
@@ -249,7 +253,14 @@ class HomePage extends StatelessWidget {
|
|||||||
offset: const Offset(2, 4),
|
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),
|
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(
|
_quickActionItem(
|
||||||
icon: Icons.link,
|
icon: Icons.link,
|
||||||
@@ -452,7 +483,7 @@ class _UserInfoCard extends StatelessWidget {
|
|||||||
width: 60,
|
width: 60,
|
||||||
height: 60,
|
height: 60,
|
||||||
child: userProvider.avatarUrl.isNotEmpty
|
child: userProvider.avatarUrl.isNotEmpty
|
||||||
? Image.network(
|
? CacheImage.network(
|
||||||
userProvider.avatarUrl,
|
userProvider.avatarUrl,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
)
|
)
|
||||||
@@ -621,7 +652,6 @@ class _SongItemCard extends StatelessWidget {
|
|||||||
|
|
||||||
String _getCoverUrl(int musicId) {
|
String _getCoverUrl(int musicId) {
|
||||||
int displayId = musicId % 10000;
|
int displayId = musicId % 10000;
|
||||||
// 注意:这里逻辑可能需要根据你的实际资源调整,通常 DX 歌曲 ID > 10000
|
|
||||||
if (musicId >= 10000) {
|
if (musicId >= 10000) {
|
||||||
String idStr = displayId.toString().padLeft(6, '0');
|
String idStr = displayId.toString().padLeft(6, '0');
|
||||||
return "https://u.mai2.link/jacket/UI_Jacket_$idStr.jpg";
|
return "https://u.mai2.link/jacket/UI_Jacket_$idStr.jpg";
|
||||||
@@ -630,26 +660,38 @@ class _SongItemCard extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取难度颜色
|
|
||||||
Color _getLevelColor(int levelIndex) {
|
Color _getLevelColor(int levelIndex) {
|
||||||
switch (levelIndex) {
|
switch (levelIndex) {
|
||||||
case 0: return Colors.green; // Basic
|
case 0: return Colors.green;
|
||||||
case 1: return Colors.blue; // Advanced
|
case 1: return Colors.blue;
|
||||||
case 2: return Colors.yellow[700]!; // Expert
|
case 2: return Colors.yellow[700]!;
|
||||||
case 3: return Colors.red; // Master
|
case 3: return Colors.red;
|
||||||
case 4: return Colors.purple; // Re:Master
|
case 4: return Colors.purple;
|
||||||
default: return Colors.grey;
|
default: return Colors.grey;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// 假设我们要显示 Master (3) 和 Re:Master (4) 的难度
|
|
||||||
// 你需要从 song.sd 或 song.dx 中解析出具体的 level_value (定数)
|
|
||||||
double? masterLv = _getLevelValue(song, 3);
|
double? masterLv = _getLevelValue(song, 3);
|
||||||
double? reMasterLv = _getLevelValue(song, 4);
|
double? reMasterLv = _getLevelValue(song, 4);
|
||||||
|
|
||||||
return Container(
|
// ⭐ 核心:用 InkWell / GestureDetector 包裹整个卡片,实现点击跳转
|
||||||
|
return InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
onTap: () {
|
||||||
|
// 跳转到歌曲详情页
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => SongDetailPage(
|
||||||
|
song: song,
|
||||||
|
userScoreCache: {}, // 你可以根据实际页面传值
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
width: 140,
|
width: 140,
|
||||||
margin: const EdgeInsets.only(right: 12),
|
margin: const EdgeInsets.only(right: 12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -665,17 +707,14 @@ class _SongItemCard extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// ✅ 1. 图片优化:增加 loadingBuilder 和 cacheWidth
|
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(12)),
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(12)),
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
height: 140,
|
height: 140,
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: Image.network(
|
child: CacheImage.network(
|
||||||
_getCoverUrl(song.id),
|
_getCoverUrl(song.id),
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
// 关键优化:指定缓存宽度,减少内存占用和解码时间
|
|
||||||
cacheWidth: 280,
|
|
||||||
loadingBuilder: (context, child, loadingProgress) {
|
loadingBuilder: (context, child, loadingProgress) {
|
||||||
if (loadingProgress == null) return child;
|
if (loadingProgress == null) return child;
|
||||||
return Container(
|
return Container(
|
||||||
@@ -689,19 +728,12 @@ class _SongItemCard extends StatelessWidget {
|
|||||||
)),
|
)),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
errorBuilder: (_, __, ___) {
|
|
||||||
return Container(
|
|
||||||
color: Colors.grey[200],
|
|
||||||
child: const Icon(Icons.music_note, size: 40, color: Colors.grey),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
// 标题
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
child: Text(
|
child: Text(
|
||||||
@@ -712,7 +744,6 @@ class _SongItemCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// 艺术家 (修复了重复显示的问题)
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
child: Text(
|
child: Text(
|
||||||
@@ -725,7 +756,6 @@ class _SongItemCard extends StatelessWidget {
|
|||||||
|
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
|
|
||||||
// ✅ 2. 底部难度标签
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -741,6 +771,7 @@ class _SongItemCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -763,7 +794,6 @@ class _SongItemCard extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 辅助方法:获取定数
|
|
||||||
double? _getLevelValue(SongModel song, int levelIndex) {
|
double? _getLevelValue(SongModel song, int levelIndex) {
|
||||||
Map? diffMap = song.dx;
|
Map? diffMap = song.dx;
|
||||||
if (diffMap == null || diffMap.isEmpty) {
|
if (diffMap == null || diffMap.isEmpty) {
|
||||||
@@ -804,7 +834,7 @@ class PosterImage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
child: Image.network(
|
child: CacheImage.network(
|
||||||
imageUrl,
|
imageUrl,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
loadingBuilder: (context, child, loadingProgress) {
|
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 '../../../providers/user_provider.dart';
|
||||||
import '../../../service/song_service.dart';
|
import '../../../service/song_service.dart';
|
||||||
import '../../../service/user_service.dart';
|
import '../../../service/user_service.dart';
|
||||||
|
import '../../tool/cacheImage.dart';
|
||||||
|
|
||||||
class MusicPage extends StatefulWidget {
|
class MusicPage extends StatefulWidget {
|
||||||
const MusicPage({super.key});
|
const MusicPage({super.key});
|
||||||
@@ -731,7 +732,7 @@ class _MusicPageState extends State<MusicPage> with SingleTickerProviderStateMix
|
|||||||
children: [
|
children: [
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(2),
|
borderRadius: BorderRadius.circular(2),
|
||||||
child: Image.network(
|
child: CacheImage.network(
|
||||||
cover,
|
cover,
|
||||||
width: 100,
|
width: 100,
|
||||||
height: 100,
|
height: 100,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import '../../../providers/user_provider.dart';
|
|||||||
import '../../../service/song_service.dart';
|
import '../../../service/song_service.dart';
|
||||||
import '../../../service/user_service.dart';
|
import '../../../service/user_service.dart';
|
||||||
import '../../../widgets/score_progress_chart.dart';
|
import '../../../widgets/score_progress_chart.dart';
|
||||||
|
import '../../tool/cacheImage.dart';
|
||||||
import '../../tool/gradientText.dart';
|
import '../../tool/gradientText.dart';
|
||||||
|
|
||||||
class SongDetailPage extends StatefulWidget {
|
class SongDetailPage extends StatefulWidget {
|
||||||
@@ -364,7 +365,7 @@ class _SongDetailPageState extends State<SongDetailPage> {
|
|||||||
children: [
|
children: [
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
child: Image.network(
|
child: CacheImage.network(
|
||||||
coverUrl,
|
coverUrl,
|
||||||
width: 90,
|
width: 90,
|
||||||
height: 90,
|
height: 90,
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import 'package:share_plus/share_plus.dart';
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
|
import '../../tool/cacheImage.dart';
|
||||||
|
|
||||||
class ScorePage extends StatefulWidget {
|
class ScorePage extends StatefulWidget {
|
||||||
const ScorePage({Key? key}) : super(key: key);
|
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 {
|
Future<void> _loadData({bool isInitialLoad = false}) async {
|
||||||
|
// 首次加载才显示加载圈
|
||||||
if (isInitialLoad) {
|
if (isInitialLoad) {
|
||||||
setState(() { _isLoading = true; });
|
setState(() { _isLoading = true; });
|
||||||
} else {
|
} else {
|
||||||
@@ -158,10 +161,30 @@ class _ScorePageState extends State<ScorePage> with SingleTickerProviderStateMix
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
final userProvider = UserProvider.instance;
|
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) {
|
if (_allSongs.isEmpty || isInitialLoad) {
|
||||||
|
debugPrint("ℹ️ 加载全量歌曲列表");
|
||||||
final songs = await SongService.getAllSongs();
|
final songs = await SongService.getAllSongs();
|
||||||
_allSongs = songs;
|
_allSongs = songs;
|
||||||
_songMap = {for (var song in songs) song.id: song};
|
_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) {
|
if (segaId == null || segaId.isEmpty) {
|
||||||
throw "请选择一个有效的 Sega ID";
|
throw "请选择一个有效的 Sega ID";
|
||||||
}
|
}
|
||||||
|
debugPrint("ℹ️ 加载 Sega 评分数据");
|
||||||
final rawData = await UserService.getSegaRatingData(token, segaId);
|
final rawData = await UserService.getSegaRatingData(token, segaId);
|
||||||
|
|
||||||
final segaId2chartlist = rawData['segaId2chartlist'] as Map<String, dynamic>?;
|
final segaId2chartlist = rawData['segaId2chartlist'] as Map<String, dynamic>?;
|
||||||
@@ -204,6 +228,7 @@ class _ScorePageState extends State<ScorePage> with SingleTickerProviderStateMix
|
|||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
|
debugPrint("ℹ️ 加载用户评分数据");
|
||||||
final scoreData = await SongService.getUserAllScores(token, name: _currentCnUserName);
|
final scoreData = await SongService.getUserAllScores(token, name: _currentCnUserName);
|
||||||
|
|
||||||
if (scoreData.containsKey('userScoreAll_')) {
|
if (scoreData.containsKey('userScoreAll_')) {
|
||||||
@@ -216,6 +241,7 @@ class _ScorePageState extends State<ScorePage> with SingleTickerProviderStateMix
|
|||||||
}
|
}
|
||||||
|
|
||||||
setState(() { _errorMessage = ''; });
|
setState(() { _errorMessage = ''; });
|
||||||
|
debugPrint("✅ 数据加载完成");
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint("❌ Load Data Error: $e");
|
debugPrint("❌ Load Data Error: $e");
|
||||||
@@ -237,7 +263,6 @@ class _ScorePageState extends State<ScorePage> with SingleTickerProviderStateMix
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
List<dynamic> get _filteredMusicList {
|
List<dynamic> get _filteredMusicList {
|
||||||
bool isNoFilter = _searchQuery.isEmpty &&
|
bool isNoFilter = _searchQuery.isEmpty &&
|
||||||
_filterLevelType == null &&
|
_filterLevelType == null &&
|
||||||
@@ -421,6 +446,16 @@ class _ScorePageState extends State<ScorePage> with SingleTickerProviderStateMix
|
|||||||
final segaCards = userProvider.availableSegaCards;
|
final segaCards = userProvider.availableSegaCards;
|
||||||
final currentSegaId = userProvider.selectedSegaId;
|
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(
|
return Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
color: Theme.of(context).cardColor,
|
color: Theme.of(context).cardColor,
|
||||||
@@ -452,9 +487,7 @@ class _ScorePageState extends State<ScorePage> with SingleTickerProviderStateMix
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
child: DropdownButtonHideUnderline(
|
child: DropdownButtonHideUnderline(
|
||||||
child: DropdownButtonFormField<String>(
|
child: DropdownButtonFormField<String>(
|
||||||
value: isSegaMode
|
value: safeValue, // <--- 这里用安全值,修复报错
|
||||||
? currentSegaId
|
|
||||||
: (currentCnUserName != null && currentCnUserName.isNotEmpty ? currentCnUserName : null),
|
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: isSegaMode ? "选择卡片" : "选择账号",
|
hintText: isSegaMode ? "选择卡片" : "选择账号",
|
||||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
@@ -1110,7 +1143,7 @@ class _ScorePageState extends State<ScorePage> with SingleTickerProviderStateMix
|
|||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
Image.network(
|
CacheImage.network(
|
||||||
coverUrl,
|
coverUrl,
|
||||||
width: 50,
|
width: 50,
|
||||||
height: 50,
|
height: 50,
|
||||||
|
|||||||
@@ -1,31 +1,320 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
import 'package:flutter/cupertino.dart';
|
||||||
import 'package:flutter/material.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 {
|
class SettingPage extends StatelessWidget {
|
||||||
const SettingPage({super.key});
|
const SettingPage({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
// 注意:这里不需要 bottomNavigationBar 了,因为外层已经有了
|
backgroundColor: isDarkMode ? Colors.black : Colors.grey[50],
|
||||||
backgroundColor: Colors.transparent, // 关键:背景透明,透出玻璃效果
|
appBar: AppBar(
|
||||||
body: CustomScrollView(
|
title: const Text(
|
||||||
slivers: [
|
"设置",
|
||||||
SliverAppBar.large(
|
style: TextStyle(
|
||||||
title: const Text('首页'),
|
fontSize: 20,
|
||||||
floating: true,
|
fontWeight: FontWeight.w600,
|
||||||
backgroundColor: Colors.blueAccent.withOpacity(0.8),
|
|
||||||
),
|
),
|
||||||
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 '../../model/user_model.dart';
|
||||||
import '../../providers/user_provider.dart';
|
import '../../providers/user_provider.dart';
|
||||||
import '../../service/sega_service.dart';
|
import '../../service/sega_service.dart';
|
||||||
|
import '../../tool/cacheImage.dart';
|
||||||
import 'login_page.dart';
|
import 'login_page.dart';
|
||||||
|
|
||||||
class UserPage extends StatefulWidget {
|
class UserPage extends StatefulWidget {
|
||||||
@@ -34,14 +35,15 @@ class _UserPageState extends State<UserPage> {
|
|||||||
Future<void> _loadRadarData() async {
|
Future<void> _loadRadarData() async {
|
||||||
final provider = Provider.of<UserProvider>(context, listen: false);
|
final provider = Provider.of<UserProvider>(context, listen: false);
|
||||||
try {
|
try {
|
||||||
|
await provider.waitInit(); // 你需要在 UserProvider 里加这个方法(我下面会给你代码)
|
||||||
final data = await provider.fetchRadarData(provider.user?.id ?? 'default_id');
|
final data = await provider.fetchRadarData(provider.user?.id ?? 'default_id');
|
||||||
setState(() {
|
setState(() {
|
||||||
_radarData = data;
|
_radarData = data;
|
||||||
});
|
});
|
||||||
if(mounted){
|
if(mounted){
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
// ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text("雷达图数据加载成功 ✅")),
|
// const SnackBar(content: Text("雷达图数据加载成功 ✅")),
|
||||||
);
|
// );
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if(mounted){
|
if(mounted){
|
||||||
@@ -993,7 +995,7 @@ class _UserPageState extends State<UserPage> {
|
|||||||
children: [
|
children: [
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
child: Image.network(
|
child: CacheImage.network(
|
||||||
provider.avatarUrl,
|
provider.avatarUrl,
|
||||||
width: 80,
|
width: 80,
|
||||||
height: 80,
|
height: 80,
|
||||||
@@ -1089,7 +1091,7 @@ class _UserPageState extends State<UserPage> {
|
|||||||
children: [
|
children: [
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: BorderRadius.circular(6),
|
||||||
child: Image.network(
|
child: CacheImage.network(
|
||||||
"https://cdn.godserver.cn/resource/static/coll/Icon/UI_Icon_$iconId.png",
|
"https://cdn.godserver.cn/resource/static/coll/Icon/UI_Icon_$iconId.png",
|
||||||
width: 50,
|
width: 50,
|
||||||
height: 50,
|
height: 50,
|
||||||
@@ -1612,7 +1614,7 @@ class _UserPageState extends State<UserPage> {
|
|||||||
data: _radarData!.map((key, value) =>
|
data: _radarData!.map((key, value) =>
|
||||||
MapEntry(key.toString(), double.tryParse(value.toString()) ?? 0.0)
|
MapEntry(key.toString(), double.tryParse(value.toString()) ?? 0.0)
|
||||||
),
|
),
|
||||||
maxValue: 1.3,
|
maxValue: 1.2,
|
||||||
lineColor: Colors.grey.shade200,
|
lineColor: Colors.grey.shade200,
|
||||||
areaColor: Colors.pink.withOpacity(0.15),
|
areaColor: Colors.pink.withOpacity(0.15),
|
||||||
borderColor: Colors.pinkAccent,
|
borderColor: Colors.pinkAccent,
|
||||||
|
|||||||
@@ -1,22 +1,33 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:typed_data'; // 确保导入,因为 uploadStegImage 需要 Uint8List
|
import 'dart:typed_data';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import '../model/user_model.dart';
|
import '../model/user_model.dart';
|
||||||
import '../service/user_service.dart';
|
import '../service/user_service.dart';
|
||||||
import '../tool/encryption_util.dart';
|
import '../tool/encryption_util.dart';
|
||||||
|
import 'dart:async'; // 必须加这个
|
||||||
|
|
||||||
class UserProvider with ChangeNotifier {
|
class UserProvider with ChangeNotifier {
|
||||||
UserModel? _user;
|
UserModel? _user;
|
||||||
String? _token;
|
String? _token;
|
||||||
List<String> _sexTags = [];
|
List<String> _sexTags = [];
|
||||||
|
|
||||||
// --- 成绩数据源相关状态 ---
|
String _scoreDataSource = 'cn';
|
||||||
String _scoreDataSource = 'cn'; // 'cn' or 'sega'
|
String? _selectedSegaId;
|
||||||
String? _selectedSegaId; // 选中的 SegaID
|
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;
|
UserModel? get user => _user;
|
||||||
String? get token => _token;
|
String? get token => _token;
|
||||||
@@ -30,7 +41,6 @@ class UserProvider with ChangeNotifier {
|
|||||||
|
|
||||||
List<SegaCard> get availableSegaCards => _user?.segaCards ?? [];
|
List<SegaCard> get availableSegaCards => _user?.segaCards ?? [];
|
||||||
|
|
||||||
// 获取可用的国服账号列表
|
|
||||||
List<String> get availableCnUserNames {
|
List<String> get availableCnUserNames {
|
||||||
final map = _user?.userName2userId;
|
final map = _user?.userName2userId;
|
||||||
if (map == null || map.isEmpty) return [];
|
if (map == null || map.isEmpty) return [];
|
||||||
@@ -52,10 +62,10 @@ class UserProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> initUser() async {
|
Future<void> initUser() async {
|
||||||
|
try {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
_token = prefs.getString("token");
|
_token = prefs.getString("token");
|
||||||
|
|
||||||
// 读取偏好设置
|
|
||||||
_scoreDataSource = prefs.getString("scoreDataSource") ?? 'cn';
|
_scoreDataSource = prefs.getString("scoreDataSource") ?? 'cn';
|
||||||
_selectedSegaId = prefs.getString("selectedSegaId");
|
_selectedSegaId = prefs.getString("selectedSegaId");
|
||||||
_selectedCnUserName = prefs.getString("selectedCnUserName");
|
_selectedCnUserName = prefs.getString("selectedCnUserName");
|
||||||
@@ -65,13 +75,11 @@ class UserProvider with ChangeNotifier {
|
|||||||
_user = await UserService.getUserInfo(_token!);
|
_user = await UserService.getUserInfo(_token!);
|
||||||
await fetchSexTags();
|
await fetchSexTags();
|
||||||
|
|
||||||
// 校验 SegaID 有效性
|
|
||||||
if (_selectedSegaId != null && !availableSegaCards.any((c) => c.segaId == _selectedSegaId)) {
|
if (_selectedSegaId != null && !availableSegaCards.any((c) => c.segaId == _selectedSegaId)) {
|
||||||
_selectedSegaId = null;
|
_selectedSegaId = null;
|
||||||
await prefs.remove("selectedSegaId");
|
await prefs.remove("selectedSegaId");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 校验国服用户名有效性
|
|
||||||
if (_selectedCnUserName != null && !availableCnUserNames.contains(_selectedCnUserName)) {
|
if (_selectedCnUserName != null && !availableCnUserNames.contains(_selectedCnUserName)) {
|
||||||
_selectedCnUserName = null;
|
_selectedCnUserName = null;
|
||||||
await prefs.remove("selectedCnUserName");
|
await prefs.remove("selectedCnUserName");
|
||||||
@@ -80,8 +88,13 @@ class UserProvider with ChangeNotifier {
|
|||||||
await logout();
|
await logout();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
// ===================== 【关键:标记初始化完成】 =====================
|
||||||
|
_isInitialized = true;
|
||||||
|
_initCompleter?.complete();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> login(String username, String twoKeyCode) async {
|
Future<void> login(String username, String twoKeyCode) async {
|
||||||
try {
|
try {
|
||||||
@@ -101,8 +114,6 @@ class UserProvider with ChangeNotifier {
|
|||||||
|
|
||||||
Future<void> register(String username, String password, String inviter) async {
|
Future<void> register(String username, String password, String inviter) async {
|
||||||
await UserService.register(username, password, inviter);
|
await UserService.register(username, password, inviter);
|
||||||
// 注意:注册后通常不直接登录,或者根据业务需求决定。这里保持原逻辑。
|
|
||||||
// await login(username, password);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> logout() async {
|
Future<void> logout() async {
|
||||||
@@ -166,7 +177,6 @@ class UserProvider with ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 设置数据源 ---
|
|
||||||
Future<void> setScoreDataSource(String source) async {
|
Future<void> setScoreDataSource(String source) async {
|
||||||
_scoreDataSource = source;
|
_scoreDataSource = source;
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
@@ -174,7 +184,6 @@ class UserProvider with ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 设置选中的 SegaID ---
|
|
||||||
Future<void> setSelectedSegaId(String? segaId) async {
|
Future<void> setSelectedSegaId(String? segaId) async {
|
||||||
_selectedSegaId = segaId;
|
_selectedSegaId = segaId;
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
@@ -186,7 +195,6 @@ class UserProvider with ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 设置选中的国服用户名 ---
|
|
||||||
Future<void> setSelectedCnUserName(String? userName) async {
|
Future<void> setSelectedCnUserName(String? userName) async {
|
||||||
_selectedCnUserName = userName;
|
_selectedCnUserName = userName;
|
||||||
final prefs = await SharedPreferences.getInstance();
|
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 {
|
Future<Map<String, dynamic>> uploadStegImage(Uint8List imageBytes, {String? segaId}) async {
|
||||||
if (_token == null) {
|
if (_token == null) {
|
||||||
throw Exception("请先登录");
|
throw Exception("请先登录");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果未指定 segaId,则使用当前选中的
|
|
||||||
final targetSegaId = segaId ?? _selectedSegaId;
|
final targetSegaId = segaId ?? _selectedSegaId;
|
||||||
|
|
||||||
if (targetSegaId == null || targetSegaId.isEmpty) {
|
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:
|
PODS:
|
||||||
|
- file_selector_macos (0.0.1):
|
||||||
|
- FlutterMacOS
|
||||||
- FlutterMacOS (1.0.0)
|
- FlutterMacOS (1.0.0)
|
||||||
- geolocator_apple (1.2.0):
|
- geolocator_apple (1.2.0):
|
||||||
- Flutter
|
- Flutter
|
||||||
@@ -19,6 +21,7 @@ PODS:
|
|||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
|
|
||||||
DEPENDENCIES:
|
DEPENDENCIES:
|
||||||
|
- file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`)
|
||||||
- FlutterMacOS (from `Flutter/ephemeral`)
|
- FlutterMacOS (from `Flutter/ephemeral`)
|
||||||
- geolocator_apple (from `Flutter/ephemeral/.symlinks/plugins/geolocator_apple/darwin`)
|
- geolocator_apple (from `Flutter/ephemeral/.symlinks/plugins/geolocator_apple/darwin`)
|
||||||
- open_file_mac (from `Flutter/ephemeral/.symlinks/plugins/open_file_mac/macos`)
|
- 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`)
|
- webview_flutter_wkwebview (from `Flutter/ephemeral/.symlinks/plugins/webview_flutter_wkwebview/darwin`)
|
||||||
|
|
||||||
EXTERNAL SOURCES:
|
EXTERNAL SOURCES:
|
||||||
|
file_selector_macos:
|
||||||
|
:path: Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos
|
||||||
FlutterMacOS:
|
FlutterMacOS:
|
||||||
:path: Flutter/ephemeral
|
:path: Flutter/ephemeral
|
||||||
geolocator_apple:
|
geolocator_apple:
|
||||||
@@ -47,6 +52,7 @@ EXTERNAL SOURCES:
|
|||||||
:path: Flutter/ephemeral/.symlinks/plugins/webview_flutter_wkwebview/darwin
|
:path: Flutter/ephemeral/.symlinks/plugins/webview_flutter_wkwebview/darwin
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
|
file_selector_macos: 9e9e068e90ebee155097d00e89ae91edb2374db7
|
||||||
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
|
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
|
||||||
geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e
|
geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e
|
||||||
open_file_mac: 76f06c8597551249bdb5e8fd8827a98eae0f4585
|
open_file_mac: 76f06c8597551249bdb5e8fd8827a98eae0f4585
|
||||||
|
|||||||
Reference in New Issue
Block a user