ver1.00.00

bindQR
This commit is contained in:
spasolreisa
2026-04-19 22:22:04 +08:00
parent 74e47971ca
commit c5c3f7c8f5
14 changed files with 920 additions and 161 deletions

View File

@@ -179,8 +179,7 @@ class HomePage extends StatelessWidget {
flex: 6,
child: const _RecommendedSongsSection(),
),
const SizedBox(width: 20), // 左右间距
// 右侧:快捷按钮区域(占 4 份宽度)
const SizedBox(width: 4),
Expanded(
flex: 6,
child: _buildQuickActionButtons(), // 快捷按钮组件

View File

@@ -3,7 +3,8 @@ import 'package:flutter/cupertino.dart';
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 'package:open_file/open_file.dart';
import 'package:webview_flutter/webview_flutter.dart'; // 【新增】导入 webview
import '../../../model/song_model.dart';
import '../../../providers/user_provider.dart';
import '../../../service/song_service.dart';
@@ -28,17 +29,34 @@ class SongDetailPage extends StatefulWidget {
class _SongDetailPageState extends State<SongDetailPage> {
String? _selectedType;
bool _isAliasExpanded = false;
// 用于跟踪下载状态key为 "type_levelId"
final Map<String, bool> _isDownloading = {};
bool _isDownloading = false;
final Map<String, List<Map<String, dynamic>>> _chartDataCache = {};
final Map<String, bool> _isLoadingChart = {};
// 【新增】用于控制 WebView 显示和加载 URL
String? _previewUrl;
late WebViewController _webViewController;
@override
void initState() {
super.initState();
_initSelectedType();
// 【新增】初始化 WebViewController
_webViewController = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setNavigationDelegate(
NavigationDelegate(
onProgress: (int progress) {
// 可选:更新加载进度
},
onPageStarted: (String url) {},
onPageFinished: (String url) {},
onHttpError: (HttpResponseError error) {},
onWebResourceError: (WebResourceError error) {},
),
);
}
void _initSelectedType() {
@@ -82,74 +100,50 @@ class _SongDetailPageState extends State<SongDetailPage> {
return all;
}
// ===================== 【新增】下载并打开 ADX 谱面功能 =====================
Future<void> _downloadAndOpenAdx(String type, Map diff) async {
int levelId = diff['level_id'] ?? 0;
String downloadKey = "${type}_$levelId";
// 防止重复点击
if (_isDownloading[downloadKey] == true) return;
setState(() {
_isDownloading[downloadKey] = true;
});
try {
// 1. 构建 URL
int songId = widget.song.id;
String url;
// UT 谱面通常没有标准的 CDN adx 下载地址,这里只处理 SD 和 DX
if (type == 'UT') {
Future<void> _downloadCurrentTypeAdx() async {
if (_isDownloading) return;
if (_selectedType == null || _selectedType == 'UT') {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("UT 宴会谱暂不支持直接下载 ADX 文件")),
const SnackBar(content: Text("仅支持下载 SD 或 DX 谱面")),
);
setState(() => _isDownloading[downloadKey] = false);
return;
}
setState(() {
_isDownloading = true;
});
try {
int songId = widget.song.id;
String url;
String type = _selectedType!;
if (type == 'SD') {
// SD: cdn.godserver.cn/resource/static/adx/00000/{songId}.adx
String idStr = songId.toString().padLeft(5, '0');
String idStr = songId.toString();
url = "https://cdn.godserver.cn/resource/static/adx/$idStr.adx";
} else {
// DX: cdn.godserver.cn/resource/static/adx/00000/{songId+10000}.adx
int dxId = songId + 10000;
String idStr = dxId.toString().padLeft(5, '0');
String idStr = dxId.toString();
url = "https://cdn.godserver.cn/resource/static/adx/$idStr.adx";
}
// 2. 下载文件
final response = await http.get(Uri.parse(url));
if (response.statusCode != 200) {
throw Exception("下载失败: HTTP ${response.statusCode}");
}
// 3. 获取临时目录并保存文件
final directory = await getApplicationDocumentsDirectory();
// 注意iOS 上 getApplicationDocumentsDirectory 是持久化的。
// 如果希望每次下载都清理,可以使用 getTemporaryDirectory()。
// 这里为了稳定性,使用 DocumentsDirectory但文件名保持唯一性。
// 生成文件名: SongTitle_Type_Level.adx
String safeTitle = widget.song.title?.replaceAll(RegExp(r'[^\w\s\u4e00-\u9fa5]'), '_') ?? "song";
String fileName = "${safeTitle}_${type}_Lv${levelId}.adx";
// 确保文件名不冲突,可以加时间戳或者随机数,这里简单处理
String fileName = "${safeTitle}_$type.adx";
final filePath = "${directory.path}/$fileName";
final file = File(filePath);
await file.writeAsBytes(response.bodyBytes);
// 4. 调用系统原生打开
// open_file 会尝试寻找能打开 .adx 的应用
final result = await OpenFile.open(filePath);
if (result.type != ResultType.done) {
// 如果没有安装能打开 adx 的应用,提示用户
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("无法打开文件: ${result.message},请确保已安装谱面编辑器")),
SnackBar(content: Text("无法打开文件: ${result.message}")),
);
}
@@ -160,29 +154,26 @@ class _SongDetailPageState extends State<SongDetailPage> {
);
} finally {
setState(() {
_isDownloading[downloadKey] = false;
_isDownloading = false;
});
}
}
// 【新增】加载图表数据逻辑保持不变...
Future<void> _loadTypeChartData(String type) async {
if (_chartDataCache.containsKey(type) || _isLoadingChart[type] == true) {
return;
}
setState(() {
_isLoadingChart[type] = true;
});
try {
final userProvider = UserProvider.instance;
final token = userProvider.token;
if (token == null || token.isEmpty) {
setState(() => _isLoadingChart[type] = false);
return;
}
int apiMusicId;
if (type == 'SD') {
apiMusicId = widget.song.id;
@@ -192,10 +183,8 @@ class _SongDetailPageState extends State<SongDetailPage> {
setState(() => _isLoadingChart[type] = false);
return;
}
final logs = await UserService.getChartLog(token, [apiMusicId]);
logs.sort((a, b) => a.time.compareTo(b.time));
final chartList = logs.map((log) => {
'achievement': log.segaChartNew?.achievement ?? 0,
'time': log.time,
@@ -206,7 +195,6 @@ class _SongDetailPageState extends State<SongDetailPage> {
'playCount': log.segaChartNew?.playCount ?? 0,
'level': log.segaChartNew?.level ?? 0,
}).toList();
setState(() {
_chartDataCache[type] = chartList;
_isLoadingChart[type] = false;
@@ -221,9 +209,7 @@ class _SongDetailPageState extends State<SongDetailPage> {
if (_chartDataCache.containsKey(cacheKey) || _isLoadingChart[cacheKey] == true) {
return;
}
setState(() => _isLoadingChart[cacheKey] = true);
try {
final userProvider = UserProvider.instance;
final token = userProvider.token;
@@ -231,10 +217,8 @@ class _SongDetailPageState extends State<SongDetailPage> {
setState(() => _isLoadingChart[cacheKey] = false);
return;
}
final logs = await UserService.getChartLog(token, [realId]);
logs.sort((a, b) => a.time.compareTo(b.time));
final chartList = logs.map((log) => {
'achievement': log.segaChartNew?.achievement ?? 0,
'time': log.time,
@@ -244,7 +228,6 @@ class _SongDetailPageState extends State<SongDetailPage> {
'deluxscoreMax': log.segaChartNew?.deluxscoreMax ?? 0,
'playCount': log.segaChartNew?.playCount ?? 0,
}).toList();
setState(() {
_chartDataCache[cacheKey] = chartList;
_isLoadingChart[cacheKey] = false;
@@ -324,6 +307,27 @@ class _SongDetailPageState extends State<SongDetailPage> {
@override
Widget build(BuildContext context) {
// 【新增】如果存在预览 URL则显示 WebView 页面
if (_previewUrl != null) {
return Scaffold(
appBar: AppBar(
title: const Text("谱面预览"),
actions: [
IconButton(
icon: const Icon(Icons.close),
onPressed: () {
setState(() {
_previewUrl = null;
});
},
),
],
),
body: WebViewWidget(controller: _webViewController),
);
}
// 否则显示原有的详情页面
final coverUrl = _getCoverUrl(widget.song.id);
final diffs = _getCurrentDifficulties();
final availableTypes = _getAvailableTypes();
@@ -332,13 +336,24 @@ class _SongDetailPageState extends State<SongDetailPage> {
appBar: AppBar(
title: Text(widget.song.title ?? "歌曲详情"),
elevation: 2,
actions: [
if (_selectedType != 'UT' && _selectedType != null)
IconButton(
icon: _isDownloading
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.download_rounded),
tooltip: "下载 ${_selectedType} 谱面",
onPressed: _isDownloading ? null : _downloadCurrentTypeAdx,
),
body: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
],
),
// 【修改】使用 Column 布局,将固定内容和滚动内容分离
body: Column(
children: [
Card(
// 1. 固定顶部:歌曲信息卡片
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 16),
child: Card(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
elevation: 4,
shadowColor: Colors.purpleAccent.withOpacity(0.2),
@@ -401,9 +416,15 @@ class _SongDetailPageState extends State<SongDetailPage> {
),
),
),
),
const SizedBox(height: 20),
// 2. 滚动区域:难度详情
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 12), // 注意vertical padding 可以减小或移除,因为顶部已有 padding
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (availableTypes.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
@@ -461,9 +482,13 @@ class _SongDetailPageState extends State<SongDetailPage> {
],
),
),
),
],
),
);
}
// 【修改】_diffItem 方法,增加预览按钮
Widget _diffItem({required String type, required Map diff}) {
int levelId = diff['level_id'] ?? 0;
final double lvValue = double.tryParse(diff['level_value']?.toString() ?? '') ?? 0;
@@ -536,10 +561,6 @@ class _SongDetailPageState extends State<SongDetailPage> {
final rating1003 = _calculateRating(lvValue, 100.3);
final rating1005 = _calculateRating(lvValue, 100.5);
// 下载状态的 Key
String downloadKey = "${type}_$levelId";
bool isDownloading = _isDownloading[downloadKey] ?? false;
return Card(
margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
@@ -570,38 +591,38 @@ class _SongDetailPageState extends State<SongDetailPage> {
),
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (hasUserScore)
const Icon(Icons.star_rounded, color: Colors.amber, size: 22),
// ===================== 【新增】下载/预览按钮 =====================
const SizedBox(width: 8),
InkWell(
onTap: isDownloading || type == 'UT' ? null : () {
_downloadAndOpenAdx(type, diff);
// 【新增】谱面预览按钮
IconButton(
icon: const Icon(Icons.preview_rounded, size: 22),
tooltip: "查看谱面预览",
onPressed: () {
// 构造 URL
// 规则: https://maimai.lxns.net/chart?chart_id={id}&difficulty={level}
int chartId;
if (type == 'SD') {
chartId = widget.song.id;
} else if (type == 'DX') {
chartId = widget.song.id + 10000;
} else {
// UT 情况,通常 UT 的 ID 在 diff['id'] 中,或者需要特殊处理
// 这里假设 UT 也使用类似的 ID 逻辑,如果 lxns 支持 UT 预览的话
// 如果 lxns 不支持 UT可以禁用此按钮或提示
chartId = (diff['id'] as num?)?.toInt() ?? 0;
}
final url = "https://maimai.lxns.net/chart?chart_id=$chartId&difficulty=$levelId";
// 加载 URL 并切换视图
_webViewController.loadRequest(Uri.parse(url));
setState(() {
_previewUrl = url;
});
},
borderRadius: BorderRadius.circular(20),
child: Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: type == 'UT' ? Colors.grey.shade300 : Theme.of(context).primaryColor.withOpacity(0.1),
shape: BoxShape.circle,
),
child: isDownloading
? SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(color),
),
)
: Icon(
Icons.download_rounded,
size: 18,
color: type == 'UT' ? Colors.grey : color,
),
),
),
],
),

View File

@@ -0,0 +1,322 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:google_mlkit_barcode_scanning/google_mlkit_barcode_scanning.dart';
import 'package:provider/provider.dart';
import '../../providers/user_provider.dart';
class UpdateScorePage extends StatefulWidget {
const UpdateScorePage({super.key});
@override
State<UpdateScorePage> createState() => _UpdateScorePageState();
}
class _UpdateScorePageState extends State<UpdateScorePage> {
final TextEditingController _segaIdController = TextEditingController();
final ImagePicker _picker = ImagePicker();
bool _isProcessing = false;
String? _statusMessage;
File? _selectedImageFile; // 用于预览
// 条码扫描器 (支持 QR Code, Data Matrix, Aztec 等)
final BarcodeScanner _barcodeScanner = BarcodeScanner(
formats: [BarcodeFormat.qrCode, BarcodeFormat.dataMatrix, BarcodeFormat.aztec],
);
@override
void dispose() {
_segaIdController.dispose();
_barcodeScanner.close();
super.dispose();
}
// 1. 选择图片并识别二维码
Future<void> _pickImageAndScan() async {
try {
final XFile? pickedFile = await _picker.pickImage(source: ImageSource.gallery);
if (pickedFile == null) return;
setState(() {
_isProcessing = true;
_statusMessage = "正在识别二维码...";
_selectedImageFile = File(pickedFile.path);
});
// 执行二维码识别
final inputImage = InputImage.fromFilePath(pickedFile.path);
final List<Barcode> barcodes = await _barcodeScanner.processImage(inputImage);
setState(() {
_isProcessing = false;
});
if (barcodes.isNotEmpty) {
// 取第一个识别到的二维码内容
final String code = barcodes.first.rawValue ?? "";
if (code.isNotEmpty) {
setState(() {
_segaIdController.text = code;
_statusMessage = "识别成功: $code";
});
} else {
setState(() {
_statusMessage = "二维码内容为空";
});
}
} else {
setState(() {
_statusMessage = "未在图中发现二维码,请手动输入";
});
}
} catch (e) {
setState(() {
_isProcessing = false;
_statusMessage = "识别失败: $e";
});
}
}
// 2. 执行隐写上传
Future<void> _handleUpload() async {
final segaId = _segaIdController.text.trim();
if (segaId.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("请输入或扫描二维码"), backgroundColor: Colors.orange),
);
return;
}
Uint8List? imageBytes;
setState(() {
_isProcessing = true;
_statusMessage = "正在准备图片...";
});
try {
// 策略:优先使用用户选择的截图作为载体。
// 如果用户没选图,或者选图后删除了,我们可以下载默认背景图作为载体。
if (_selectedImageFile != null) {
imageBytes = await _selectedImageFile!.readAsBytes();
} else {
// 下载默认背景图作为隐写载体
final httpClient = HttpClient();
final request = await httpClient.getUrl(Uri.parse('https://union.godserver.cn/assets/jpeg/20180621142015_5FmGZ-wYXkyL4y.jpeg'));
final response = await request.close();
// 辅助函数:将 HttpClientResponse 转为 Uint8List
final bytes = <int>[];
await for (final chunk in response) {
bytes.addAll(chunk);
}
imageBytes = Uint8List.fromList(bytes);
}
if (imageBytes == null || imageBytes.isEmpty) {
throw Exception("图片数据无效");
}
setState(() {
_statusMessage = "正在隐写并上传...";
});
// 调用 Provider 上传
final userProvider = context.read<UserProvider>();
final result = await userProvider.uploadStegImage(imageBytes, segaId: segaId);
setState(() {
_isProcessing = false;
_statusMessage = "上传成功!";
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("上传成功: ${result['msg']}"), backgroundColor: Colors.green),
);
// 可选:清空表单
// _segaIdController.clear();
// _selectedImageFile = null;
}
} catch (e) {
setState(() {
_isProcessing = false;
_statusMessage = "上传失败: $e";
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("错误: $e"), backgroundColor: Colors.red),
);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Stack(
fit: StackFit.expand,
children: [
// 1. 背景图片
Image.network(
'https://union.godserver.cn/assets/jpeg/20180621142015_5FmGZ-wYXkyL4y.jpeg',
fit: BoxFit.cover,
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) return child;
return Container(color: Colors.black);
},
errorBuilder: (context, error, stackTrace) {
return Container(color: Colors.grey[900]);
},
),
// 2. 深色遮罩
Container(color: Colors.black.withOpacity(0.7)),
// 3. 主体内容
SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// 标题
const Text(
"更新成绩 / 二维码",
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const SizedBox(height: 30),
// 卡片容器
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.15),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.white.withOpacity(0.2)),
),
child: Column(
children: [
// 输入框
TextField(
controller: _segaIdController,
style: const TextStyle(color: Colors.white),
decoration: InputDecoration(
labelText: "Sega ID / 二维码内容",
labelStyle: const TextStyle(color: Colors.white70),
hintText: "手动输入或点击下方按钮识别",
hintStyle: const TextStyle(color: Colors.white38),
prefixIcon: const Icon(Icons.qr_code, color: Colors.white70),
enabledBorder: OutlineInputBorder(
borderSide: const BorderSide(color: Colors.white38),
borderRadius: BorderRadius.circular(12),
),
focusedBorder: OutlineInputBorder(
borderSide: const BorderSide(color: Colors.white),
borderRadius: BorderRadius.circular(12),
),
),
),
const SizedBox(height: 20),
// 图片预览区域 (如果有)
if (_selectedImageFile != null)
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.file(
_selectedImageFile!,
height: 150,
fit: BoxFit.contain,
),
),
if (_selectedImageFile != null) const SizedBox(height: 15),
// 操作按钮组
Row(
children: [
// 识别二维码按钮
Expanded(
child: ElevatedButton.icon(
onPressed: _isProcessing ? null : _pickImageAndScan,
icon: const Icon(Icons.camera_alt),
label: const Text("识别截图"),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blueAccent,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
),
const SizedBox(width: 10),
// 上传按钮
Expanded(
flex: 1,
child: ElevatedButton.icon(
onPressed: _isProcessing ? null : _handleUpload,
icon: _isProcessing
? const SizedBox(
width: 16, height: 16,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
)
: const Icon(Icons.cloud_upload),
label: Text(_isProcessing ? "处理中..." : "上传"),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
),
],
),
],
),
),
// 状态提示
if (_statusMessage != null)
Padding(
padding: const EdgeInsets.only(top: 20),
child: Text(
_statusMessage!,
style: TextStyle(
color: _statusMessage!.contains("失败") || _statusMessage!.contains("错误")
? Colors.redAccent
: Colors.white70,
fontSize: 14,
),
textAlign: TextAlign.center,
),
),
],
),
),
),
),
],
),
);
}
}

View File

@@ -1,4 +1,5 @@
import 'dart:convert';
import 'dart:typed_data'; // 确保导入,因为 uploadStegImage 需要 Uint8List
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../model/user_model.dart';
@@ -25,11 +26,11 @@ class UserProvider with ChangeNotifier {
String get scoreDataSource => _scoreDataSource;
String? get selectedSegaId => _selectedSegaId;
String? get selectedCnUserName => _selectedCnUserName; // Getter for CN username
String? get selectedCnUserName => _selectedCnUserName;
List<SegaCard> get availableSegaCards => _user?.segaCards ?? [];
// 获取可用的国服账号列表 (从 userName2userId 提取 keys)
// 获取可用的国服账号列表
List<String> get availableCnUserNames {
final map = _user?.userName2userId;
if (map == null || map.isEmpty) return [];
@@ -82,7 +83,6 @@ class UserProvider with ChangeNotifier {
notifyListeners();
}
// ... (login, register, logout 保持不变) ...
Future<void> login(String username, String twoKeyCode) async {
try {
_token = await UserService.login(username, twoKeyCode);
@@ -101,18 +101,22 @@ class UserProvider with ChangeNotifier {
Future<void> register(String username, String password, String inviter) async {
await UserService.register(username, password, inviter);
await login(username, password);
// 注意:注册后通常不直接登录,或者根据业务需求决定。这里保持原逻辑。
// await login(username, password);
}
Future<void> logout() async {
_token = null;
_user = null;
_selectedSegaId = null;
_selectedCnUserName = null;
final prefs = await SharedPreferences.getInstance();
await prefs.remove("token");
await prefs.remove("selectedSegaId");
await prefs.remove("selectedCnUserName");
notifyListeners();
}
// ... (saveUserInfo, refreshToken, fetchRadarData, fetchSexTags, updateUser 保持不变) ...
Future<void> saveUserInfo() async {
if (_token == null || _user == null) return;
try {
@@ -182,7 +186,7 @@ class UserProvider with ChangeNotifier {
notifyListeners();
}
// --- 【新增】设置选中的国服用户名 ---
// --- 设置选中的国服用户名 ---
Future<void> setSelectedCnUserName(String? userName) async {
_selectedCnUserName = userName;
final prefs = await SharedPreferences.getInstance();
@@ -203,7 +207,6 @@ class UserProvider with ChangeNotifier {
}
try {
// 调用上传
final result = await UserService.uploadSegaRating(
_token!,
_selectedSegaId!,
@@ -221,4 +224,38 @@ class UserProvider with ChangeNotifier {
rethrow;
}
}
// ================= 新增:上传隐写图片 =================
/// 上传带有隐写数据的图片
/// [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) {
throw Exception("请提供或选择要隐藏的 SegaID");
}
try {
print("🚀 开始隐写并上传... SegaID: $targetSegaId");
final result = await UserService.uploadStegImage(
token: _token!,
segaId: targetSegaId,
originalImageBytes: imageBytes,
);
print("✅ 隐写上传成功: ${result['msg']}");
return result;
} catch (e) {
print("❌ 隐写上传失败: $e");
rethrow;
}
}
}

View File

@@ -0,0 +1,114 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:image/image.dart' as img;
import '../tool/encryption_util.dart';
class SteganographyUtil {
static const String MAGIC_HEADER = 'SGWCMAID';
/// 图片隐写:将数据藏进图片 R 通道最低位
static Uint8List hideDataInImage(Uint8List imageBytes, String secretData) {
if (!secretData.startsWith(MAGIC_HEADER)) {
throw Exception('数据格式不正确,必须以 $MAGIC_HEADER 开头');
}
// 1. 加密 + 压缩
final encryptedString = EncryptionUtil.encryptAndCompress(secretData);
// 2. 转为字节并添加 \0 结束符
final dataBytes = utf8.encode(encryptedString);
final payload = Uint8List(dataBytes.length + 1);
payload.setRange(0, dataBytes.length, dataBytes);
payload[dataBytes.length] = 0; // \0
// 3. 解码图片
final image = img.decodeImage(imageBytes);
if (image == null) throw Exception('图片解码失败');
// 4. 获取像素数据
final imageData = image.data;
if (imageData == null) throw Exception('图片数据为空');
// 【关键修复】ByteBuffer 必须转换为 Uint8List 才能进行 [] 操作和获取 length
// 注意asUint8List() 返回的是原 buffer 的视图,修改它会直接修改 image 数据
final Uint8List pixels = imageData.buffer.asUint8List();
// 检查容量 (R通道只有 width * height 个字节可用)
// pixels.length 是总字节数 (width * height * 4)
final maxBits = pixels.length ~/ 4;
if (payload.length * 8 > maxBits) {
throw Exception('图片容量不足!需要 ${payload.length * 8} bits图片仅提供 ${maxBits} bits');
}
// 5. 执行 LSB 隐写
_writeLsbRChannel(pixels, payload);
// 6. 导出 PNG
return img.encodePng(image);
}
/// 从图片中提取数据
static String? extractDataFromImage(Uint8List imageBytes) {
final image = img.decodeImage(imageBytes);
if (image == null) return null;
final imageData = image.data;
if (imageData == null) return null;
// 【关键修复】转换为 Uint8List
final Uint8List pixels = imageData.buffer.asUint8List();
final List<int> extractedBytes = [];
int currentByteValue = 0;
int bitCount = 0;
// 遍历像素 (RGBA, 步长 4)
for (int i = 0; i < pixels.length; i += 4) {
// 只取 R 通道 (index i) 的最低位
int bit = pixels[i] & 1;
currentByteValue = (currentByteValue << 1) | bit;
bitCount++;
if (bitCount == 8) {
if (currentByteValue == 0) {
break; // 遇到 \0 停止
}
extractedBytes.add(currentByteValue);
currentByteValue = 0;
bitCount = 0;
}
}
if (extractedBytes.isEmpty) return null;
try {
return utf8.decode(extractedBytes);
} catch (e) {
return null;
}
}
/// 核心写入:仅修改 R 通道
static void _writeLsbRChannel(Uint8List pixels, Uint8List payload) {
int payloadIndex = 0;
int bitIndex = 0; // 0-7
for (int i = 0; i < pixels.length; i += 4) {
if (payloadIndex >= payload.length) break;
int currentByte = payload[payloadIndex];
// 取当前字节的第 bitIndex 位 (从高位到低位)
int bit = (currentByte >> (7 - bitIndex)) & 1;
// 修改 R 通道: 清除最低位,写入新位
pixels[i] = (pixels[i] & 0xFE) | bit;
bitIndex++;
if (bitIndex > 7) {
bitIndex = 0;
payloadIndex++;
}
}
}
}

View File

@@ -1,8 +1,10 @@
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:unionapp/service/steganography_util.dart';
import '../model/song_model.dart';
import '../model/user_model.dart';
import '../tool/encryption_util.dart';
import 'dart:typed_data';
class UserService {
static final Dio _dio = Dio();
@@ -337,4 +339,82 @@ class UserService {
throw _getErrorMessage(e);
}
}
/// 上传隐写图片
/// [token] 用户令牌
/// [segaId] 要隐藏的 Sega ID
/// [originalImageBytes] 原始图片字节数据 (Uint8List)
static Future<Map<String, dynamic>> uploadStegImage({
required String token,
required String segaId,
required Uint8List originalImageBytes,
}) async {
try {
// 1. 拼接隐写数据SGWCMAID 开头)
final secretData = 'SGWCMAID$segaId';
// 2. 执行隐写 + 加密
// 使用 Uint8List.fromList 确保类型兼容,避免 typed_data_patch 错误
final safeImageBytes = Uint8List.fromList(originalImageBytes);
final stegImageBytes = SteganographyUtil.hideDataInImage(
safeImageBytes,
secretData,
);
// 3. 构建上传表单
final formData = FormData.fromMap({
'path': MultipartFile.fromBytes(
stegImageBytes,
filename: 'encoded.png',
contentType: DioMediaType.parse('image/png'),
),
});
// 4. 前置接口(完全对齐 JS 逻辑)
await _dio.get(
'$baseUrl/api/sega/3egrsz53et/w35eshdk76e',
options: Options(headers: {"Authorization": token}),
);
// 5. 【核心上传】隐写图片
final res = await _dio.post(
'$baseUrl/api/sega/steg',
data: formData,
options: Options(
headers: {
"Authorization": token,
// Dio 会自动处理 multipart/form-data 的 boundary通常不需要手动指定 Content-Type
// 但为了严格对齐 JS如果后端强校验可以保留不过 Dio 推荐让它在 FormData 时自动设置
},
),
);
// 6. 后续接口链(对齐 JS 逻辑)
await _dio.post(
'$baseUrl/api/sega/454szhghs/45ustzdxzsd',
options: Options(headers: {"Authorization": token}),
);
await _dio.get(
'$baseUrl/api/sega/stxfghb347/b46se',
options: Options(headers: {"Authorization": token}),
);
await _dio.put(
'$baseUrl/api/sega/sertjdffgh/4666drzzf',
options: Options(headers: {"Authorization": token}),
);
// 返回最终结果
if (res.data is Map) {
return Map<String, dynamic>.from(res.data);
}
return {"code": 200, "msg": "success", "data": res.data};
} on DioException catch (e) {
throw _getErrorMessage(e);
} catch (e) {
throw Exception("隐写或上传失败: $e");
}
}
}

View File

@@ -6,10 +6,14 @@
#include "generated_plugin_registrant.h"
#include <file_selector_linux/file_selector_plugin.h>
#include <open_file_linux/open_file_linux_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
file_selector_plugin_register_with_registrar(file_selector_linux_registrar);
g_autoptr(FlPluginRegistrar) open_file_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "OpenFileLinuxPlugin");
open_file_linux_plugin_register_with_registrar(open_file_linux_registrar);

View File

@@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
file_selector_linux
open_file_linux
url_launcher_linux
)

View File

@@ -5,18 +5,22 @@
import FlutterMacOS
import Foundation
import file_selector_macos
import geolocator_apple
import open_file_mac
import package_info_plus
import share_plus
import shared_preferences_foundation
import url_launcher_macos
import webview_flutter_wkwebview
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
OpenFilePlugin.register(with: registry.registrar(forPlugin: "OpenFilePlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
WebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "WebViewFlutterPlugin"))
}

View File

@@ -3,6 +3,8 @@ PODS:
- geolocator_apple (1.2.0):
- Flutter
- FlutterMacOS
- open_file_mac (1.0.3):
- FlutterMacOS
- package_info_plus (0.0.1):
- FlutterMacOS
- share_plus (0.0.1):
@@ -12,20 +14,27 @@ PODS:
- FlutterMacOS
- url_launcher_macos (0.0.1):
- FlutterMacOS
- webview_flutter_wkwebview (0.0.1):
- Flutter
- FlutterMacOS
DEPENDENCIES:
- 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`)
- package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`)
- share_plus (from `Flutter/ephemeral/.symlinks/plugins/share_plus/macos`)
- shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`)
- url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`)
- webview_flutter_wkwebview (from `Flutter/ephemeral/.symlinks/plugins/webview_flutter_wkwebview/darwin`)
EXTERNAL SOURCES:
FlutterMacOS:
:path: Flutter/ephemeral
geolocator_apple:
:path: Flutter/ephemeral/.symlinks/plugins/geolocator_apple/darwin
open_file_mac:
:path: Flutter/ephemeral/.symlinks/plugins/open_file_mac/macos
package_info_plus:
:path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos
share_plus:
@@ -34,14 +43,18 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin
url_launcher_macos:
:path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos
webview_flutter_wkwebview:
:path: Flutter/ephemeral/.symlinks/plugins/webview_flutter_wkwebview/darwin
SPEC CHECKSUMS:
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e
open_file_mac: 76f06c8597551249bdb5e8fd8827a98eae0f4585
package_info_plus: f0052d280d17aa382b932f399edf32507174e870
share_plus: 3c787998077d6b31e839225a282e9e27edf99274
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
url_launcher_macos: f87a979182d112f911de6820aefddaf56ee9fbfd
webview_flutter_wkwebview: 8ebf4fded22593026f7dbff1fbff31ea98573c8d
PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009

View File

@@ -177,6 +177,38 @@ packages:
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
version: "7.0.1"
file_selector_linux:
dependency: transitive
description:
name: file_selector_linux
sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0"
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
version: "0.9.4"
file_selector_macos:
dependency: transitive
description:
name: file_selector_macos
sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a"
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
version: "0.9.5"
file_selector_platform_interface:
dependency: transitive
description:
name: file_selector_platform_interface
sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85"
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
version: "2.7.0"
file_selector_windows:
dependency: transitive
description:
name: file_selector_windows
sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd"
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
version: "0.9.3+5"
fixnum:
dependency: transitive
description:
@@ -214,6 +246,14 @@ packages:
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
version: "6.2.1"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
name: flutter_plugin_android_lifecycle
sha256: "38d1c268de9097ff59cf0e844ac38759fc78f76836d37edad06fa21e182055a0"
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
version: "2.0.34"
flutter_test:
dependency: "direct dev"
description: flutter
@@ -336,6 +376,22 @@ packages:
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
version: "2.1.3"
google_mlkit_barcode_scanning:
dependency: "direct dev"
description:
name: google_mlkit_barcode_scanning
sha256: "965183a8cd5cef8477ceea5dbdf29c34a739cf0cfbf1bdad54cd3f9f1807afe5"
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
version: "0.10.0"
google_mlkit_commons:
dependency: transitive
description:
name: google_mlkit_commons
sha256: "046586b381cdd139f7f6a05ad6998f7e339d061bd70158249907358394b5f496"
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
version: "0.6.1"
gsettings:
dependency: transitive
description:
@@ -376,6 +432,78 @@ packages:
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
version: "4.1.2"
image:
dependency: "direct dev"
description:
name: image
sha256: f31d52537dc417fdcde36088fdf11d191026fd5e4fae742491ebd40e5a8bea7d
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
version: "4.3.0"
image_picker:
dependency: "direct dev"
description:
name: image_picker
sha256: "784210112be18ea55f69d7076e2c656a4e24949fa9e76429fe53af0c0f4fa320"
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
version: "1.2.1"
image_picker_android:
dependency: transitive
description:
name: image_picker_android
sha256: "66810af8e99b2657ee98e5c6f02064f69bb63f7a70e343937f70946c5f8c6622"
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
version: "0.8.13+16"
image_picker_for_web:
dependency: transitive
description:
name: image_picker_for_web
sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214"
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
version: "3.1.1"
image_picker_ios:
dependency: transitive
description:
name: image_picker_ios
sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
version: "0.8.13+6"
image_picker_linux:
dependency: transitive
description:
name: image_picker_linux
sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4"
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
version: "0.2.2"
image_picker_macos:
dependency: transitive
description:
name: image_picker_macos
sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91"
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
version: "0.2.2+1"
image_picker_platform_interface:
dependency: transitive
description:
name: image_picker_platform_interface
sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c"
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
version: "2.11.1"
image_picker_windows:
dependency: transitive
description:
name: image_picker_windows
sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
version: "0.2.2"
intl:
dependency: transitive
description:
@@ -981,6 +1109,38 @@ packages:
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
version: "1.1.1"
webview_flutter:
dependency: "direct dev"
description:
name: webview_flutter
sha256: a3da219916aba44947d3a5478b1927876a09781174b5a2b67fa5be0555154bf9
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
version: "4.13.1"
webview_flutter_android:
dependency: transitive
description:
name: webview_flutter_android
sha256: f560f57d0f529c1dcdaf4edc3a3217b099560622f9f4a10b6bdbb566553c61ea
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
version: "4.11.0"
webview_flutter_platform_interface:
dependency: transitive
description:
name: webview_flutter_platform_interface
sha256: "1221c1b12f5278791042f2ec2841743784cf25c5a644e23d6680e5d718824f04"
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
version: "2.15.1"
webview_flutter_wkwebview:
dependency: transitive
description:
name: webview_flutter_wkwebview
sha256: e15d8828e014291324a4d0cf6e272090167f4fa5673ffcf8fe446f4a4cd35861
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
version: "3.24.3"
win32:
dependency: transitive
description:

View File

@@ -44,11 +44,6 @@ dev_dependencies:
geolocator: ^14.0.2
html: ^0.15.4
geocoding: ^4.0.0
# The "flutter_lints" package below contains a set of recommended lints to
# encourage good coding practices. The lint set provided by the package is
# activated in the `analysis_options.yaml` file located at the root of your
# package. See that file for information about deactivating specific lint
# rules and activating additional ones.
flutter_lints: ^6.0.0
provider: ^6.1.2
dio: ^5.4.0 # 网络请求
@@ -61,6 +56,11 @@ dev_dependencies:
url_launcher: ^6.2.2
share_plus: ^7.2.2
open_file: ^3.3.2
webview_flutter: ^4.8.0
image: ^4.0.17
google_mlkit_barcode_scanning: ^0.10.0 # 用于识别二维码/条形码
image_picker: ^1.0.4
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec

View File

@@ -6,11 +6,14 @@
#include "generated_plugin_registrant.h"
#include <file_selector_windows/file_selector_windows.h>
#include <geolocator_windows/geolocator_windows.h>
#include <share_plus/share_plus_windows_plugin_c_api.h>
#include <url_launcher_windows/url_launcher_windows.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
FileSelectorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FileSelectorWindows"));
GeolocatorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("GeolocatorWindows"));
SharePlusWindowsPluginCApiRegisterWithRegistrar(

View File

@@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
file_selector_windows
geolocator_windows
share_plus
url_launcher_windows