ver1.00.00

update
This commit is contained in:
spasolreisa
2026-04-21 00:28:41 +08:00
parent b985cd1f9e
commit f5f62c828d
13 changed files with 1496 additions and 175 deletions

View File

@@ -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>

View File

@@ -73,5 +73,10 @@
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>LSApplicationQueriesSchemes</key>
<array>
<string>mqq</string>
</array>
</dict>
</plist>

View File

@@ -8,7 +8,6 @@ void main() {
WidgetsFlutterBinding.ensureInitialized();
HardwareKeyboard.instance.clearState();
// 🔥 不 await直接取实例
final userProvider = UserProvider.instance;
// 🔥 后台异步初始化,不卡界面

View File

@@ -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,26 +660,38 @@ 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(
// ⭐ 核心:用 InkWell / GestureDetector 包裹整个卡片,实现点击跳转
return InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () {
// 跳转到歌曲详情页
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => SongDetailPage(
song: song,
userScoreCache: {}, // 你可以根据实际页面传值
),
),
);
},
child: Container(
width: 140,
margin: const EdgeInsets.only(right: 12),
decoration: BoxDecoration(
@@ -665,17 +707,14 @@ class _SongItemCard extends StatelessWidget {
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(
child: CacheImage.network(
_getCoverUrl(song.id),
fit: BoxFit.cover,
// 关键优化:指定缓存宽度,减少内存占用和解码时间
cacheWidth: 280,
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) return child;
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),
// 标题
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(
@@ -712,7 +744,6 @@ class _SongItemCard extends StatelessWidget {
),
),
// 艺术家 (修复了重复显示的问题)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(
@@ -725,7 +756,6 @@ class _SongItemCard extends StatelessWidget {
const SizedBox(height: 6),
// ✅ 2. 底部难度标签
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Row(
@@ -741,6 +771,7 @@ class _SongItemCard extends StatelessWidget {
),
],
),
),
);
}
@@ -763,10 +794,9 @@ 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;
}
@@ -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
View 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),
),
);
}
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,
);
}
}

View File

@@ -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,

View File

@@ -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,10 +62,10 @@ class UserProvider with ChangeNotifier {
}
Future<void> initUser() async {
try {
final prefs = await SharedPreferences.getInstance();
_token = prefs.getString("token");
// 读取偏好设置
_scoreDataSource = prefs.getString("scoreDataSource") ?? 'cn';
_selectedSegaId = prefs.getString("selectedSegaId");
_selectedCnUserName = prefs.getString("selectedCnUserName");
@@ -65,13 +75,11 @@ class UserProvider with ChangeNotifier {
_user = await UserService.getUserInfo(_token!);
await fetchSexTags();
// 校验 SegaID 有效性
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");
@@ -80,8 +88,13 @@ class UserProvider with ChangeNotifier {
await logout();
}
}
} finally {
// ===================== 【关键:标记初始化完成】 =====================
_isInitialized = true;
_initCompleter?.complete();
notifyListeners();
}
}
Future<void> login(String username, String twoKeyCode) async {
try {
@@ -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
View 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();
},
);
}
}

View File

@@ -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