You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
594 lines
19 KiB
594 lines
19 KiB
import 'dart:io';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:get/get.dart';
|
|
import 'package:get_storage/get_storage.dart';
|
|
// import 'package:google_mlkit_text_recognition/google_mlkit_text_recognition.dart';
|
|
import 'package:image_picker/image_picker.dart';
|
|
import 'package:image/image.dart' as img;
|
|
import 'package:path/path.dart' as path;
|
|
import 'package:permission_handler/permission_handler.dart';
|
|
|
|
import '../../../data/services/volcano_translation_service.dart';
|
|
import '../../../data/services/language_manager.dart';
|
|
import '../../../core/utils/logger.dart';
|
|
import '../models/picture_translation_models.dart';
|
|
|
|
class PictureTranslationController extends GetxController {
|
|
final ImagePicker _imagePicker = ImagePicker();
|
|
// final TextRecognizer _textRecognizer =
|
|
// TextRecognizer(script: TextRecognitionScript.chinese);
|
|
final GetStorage _storage = GetStorage();
|
|
|
|
final VolcanoTranslationService _translationService =
|
|
Get.find<VolcanoTranslationService>();
|
|
final LanguageManager languageManager = Get.find<LanguageManager>();
|
|
|
|
// UI状态
|
|
final sourceLanguageChineseName = 'autoDetect'.obs;
|
|
final targetLanguageChineseName = '英语'.obs;
|
|
final sourceText = ''.obs;
|
|
final translatedText = ''.obs;
|
|
final translatedImageBytes = Rx<Uint8List?>(null); // 回贴后的图片二进制
|
|
final isProcessing = false.obs;
|
|
final pickedImageFile = Rx<File?>(null);
|
|
final isSourceLanguageUserSet = false.obs;
|
|
final isTargetLanguageUserSet = false.obs;
|
|
final currentStep = ProcessingStep(
|
|
title: 'waitingForAction'.tr, // 等待操作
|
|
description: 'pleaseSelectImageToStart'.tr, // 请选择图片开始翻译
|
|
status: ProcessingStatus.idle,
|
|
).obs;
|
|
|
|
// 历史记录
|
|
final historyList = <PictureTranslationHistory>[].obs;
|
|
final isLoadingHistory = false.obs;
|
|
|
|
// 图片处理相关
|
|
final imageQuality = 85.obs;
|
|
final maxImageSize = 1920.obs;
|
|
|
|
static const String _historyKey = 'picture_translation_history';
|
|
static const String _settingsKey = 'picture_translation_settings';
|
|
String? _lastDetectedSourceLanguageCode;
|
|
|
|
@override
|
|
void onInit() {
|
|
super.onInit();
|
|
_loadSettings();
|
|
_loadHistory();
|
|
}
|
|
|
|
@override
|
|
void onClose() {
|
|
// _textRecognizer.close();
|
|
super.onClose();
|
|
}
|
|
|
|
/// 加载设置
|
|
void _loadSettings() {
|
|
try {
|
|
final settings = _storage.read(_settingsKey) as Map<String, dynamic>?;
|
|
if (settings != null) {
|
|
final langs = languageManager.getAllChineseNames();
|
|
|
|
final appDefaultTargetLanguage = _getLanguageByAppLocale();
|
|
|
|
final bool sourceUserSet =
|
|
(settings['sourceLanguageUserSet'] == true) ||
|
|
((settings['sourceLanguageUserSet'] == null) &&
|
|
(settings['sourceLanguage'] != null) &&
|
|
(settings['sourceLanguage'] != '中文(简体)'));
|
|
|
|
bool targetUserSet = settings['targetLanguageUserSet'] == true;
|
|
final bool shouldMigrateTargetUserSetKey =
|
|
settings['targetLanguageUserSet'] == null;
|
|
if (shouldMigrateTargetUserSetKey) {
|
|
final savedTargetLanguage = settings['targetLanguage'];
|
|
if (savedTargetLanguage != null &&
|
|
appDefaultTargetLanguage != null &&
|
|
savedTargetLanguage == appDefaultTargetLanguage) {
|
|
settings.remove('targetLanguage');
|
|
targetUserSet = false;
|
|
} else {
|
|
targetUserSet = savedTargetLanguage != null &&
|
|
savedTargetLanguage != 'autoDetect' &&
|
|
savedTargetLanguage != '英语';
|
|
}
|
|
}
|
|
|
|
isSourceLanguageUserSet.value = sourceUserSet;
|
|
isTargetLanguageUserSet.value = targetUserSet;
|
|
|
|
final savedSourceLanguage = settings['sourceLanguage'];
|
|
if (sourceUserSet &&
|
|
savedSourceLanguage != null &&
|
|
(savedSourceLanguage == 'autoDetect' ||
|
|
langs.contains(savedSourceLanguage))) {
|
|
sourceLanguageChineseName.value = savedSourceLanguage;
|
|
} else {
|
|
sourceLanguageChineseName.value = 'autoDetect';
|
|
}
|
|
|
|
final savedTargetLanguage = settings['targetLanguage'];
|
|
if (targetUserSet &&
|
|
savedTargetLanguage != null &&
|
|
savedTargetLanguage != 'autoDetect' &&
|
|
langs.contains(savedTargetLanguage)) {
|
|
targetLanguageChineseName.value = savedTargetLanguage;
|
|
} else {
|
|
if (appDefaultTargetLanguage != null &&
|
|
langs.contains(appDefaultTargetLanguage)) {
|
|
targetLanguageChineseName.value = appDefaultTargetLanguage;
|
|
} else {
|
|
targetLanguageChineseName.value = '英语';
|
|
}
|
|
}
|
|
|
|
if (shouldMigrateTargetUserSetKey) {
|
|
_saveSettings();
|
|
}
|
|
|
|
imageQuality.value = settings['imageQuality'] ?? 85;
|
|
maxImageSize.value = settings['maxImageSize'] ?? 1920;
|
|
} else {
|
|
final appDefaultTargetLanguage = _getLanguageByAppLocale();
|
|
final langs = languageManager.getAllChineseNames();
|
|
if (appDefaultTargetLanguage != null &&
|
|
langs.contains(appDefaultTargetLanguage)) {
|
|
targetLanguageChineseName.value = appDefaultTargetLanguage;
|
|
} else {
|
|
targetLanguageChineseName.value = '英语';
|
|
}
|
|
}
|
|
} catch (e) {
|
|
Logger.error('PictureTranslation', '加载设置失败: $e');
|
|
}
|
|
}
|
|
|
|
/// 保存设置
|
|
void _saveSettings() {
|
|
try {
|
|
_storage.write(_settingsKey, {
|
|
if (isSourceLanguageUserSet.value)
|
|
'sourceLanguage': sourceLanguageChineseName.value,
|
|
if (isTargetLanguageUserSet.value)
|
|
'targetLanguage': targetLanguageChineseName.value,
|
|
'sourceLanguageUserSet': isSourceLanguageUserSet.value,
|
|
'targetLanguageUserSet': isTargetLanguageUserSet.value,
|
|
'imageQuality': imageQuality.value,
|
|
'maxImageSize': maxImageSize.value,
|
|
});
|
|
} catch (e) {
|
|
Logger.error('PictureTranslation', '保存设置失败: $e');
|
|
}
|
|
}
|
|
|
|
/// 加载历史记录
|
|
void _loadHistory() {
|
|
try {
|
|
isLoadingHistory.value = true;
|
|
final historyData = _storage.read(_historyKey) as List<dynamic>?;
|
|
if (historyData != null) {
|
|
historyList.value = historyData
|
|
.map((item) => PictureTranslationHistory.fromJson(
|
|
item as Map<String, dynamic>))
|
|
.toList();
|
|
// 按时间倒序排列
|
|
historyList.sort((a, b) => b.timestamp.compareTo(a.timestamp));
|
|
}
|
|
} catch (e) {
|
|
Logger.error('PictureTranslation', '加载历史记录失败: $e');
|
|
} finally {
|
|
isLoadingHistory.value = false;
|
|
}
|
|
}
|
|
|
|
/// 保存历史记录
|
|
void _saveHistory() {
|
|
try {
|
|
final historyData = historyList.map((item) => item.toJson()).toList();
|
|
_storage.write(_historyKey, historyData);
|
|
} catch (e) {
|
|
Logger.error('PictureTranslation', '保存历史记录失败: $e');
|
|
}
|
|
}
|
|
|
|
/// 添加历史记录
|
|
void _addToHistory(PictureTranslationHistory history) {
|
|
historyList.insert(0, history);
|
|
// 限制历史记录数量,最多保存100条
|
|
if (historyList.length > 100) {
|
|
historyList.removeRange(100, historyList.length);
|
|
}
|
|
_saveHistory();
|
|
}
|
|
|
|
/// 删除历史记录
|
|
void deleteHistory(String id) {
|
|
historyList.removeWhere((item) => item.id == id);
|
|
_saveHistory();
|
|
// Get.snackbar('成功', '已删除历史记录');
|
|
}
|
|
|
|
/// 清空历史记录
|
|
void clearHistory() {
|
|
historyList.clear();
|
|
_storage.remove(_historyKey);
|
|
// Get.snackbar('成功', '已清空所有历史记录');
|
|
}
|
|
|
|
/// 检查权限
|
|
Future<bool> _checkPermissions(ImageSource source) async {
|
|
if (source == ImageSource.camera) {
|
|
final status = await Permission.camera.request();
|
|
if (!status.isGranted) {
|
|
Get.snackbar(
|
|
'permissionRequired'.tr, // 权限需求
|
|
'cameraPermissionRequired'.tr); // 需要相机权限才能拍照
|
|
return false;
|
|
}
|
|
} else {
|
|
final status = await Permission.photos.request();
|
|
if (!status.isGranted) {
|
|
Get.snackbar(
|
|
'permissionRequired'.tr, // 权限需求
|
|
'photoAlbumPermissionRequired'.tr); // 需要相册权限才能选择图片
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/// 更新处理步骤
|
|
void _updateStep(String title, String description, ProcessingStatus status,
|
|
{double progress = 0.0, String? error}) {
|
|
currentStep.value = ProcessingStep(
|
|
title: title,
|
|
description: description,
|
|
status: status,
|
|
progress: progress,
|
|
error: error,
|
|
);
|
|
}
|
|
|
|
/// 从相册选择
|
|
Future<void> pickFromGallery() async {
|
|
//if (!(await _checkPermissions(ImageSource.gallery))) return;
|
|
await _pickAndProcess(ImageSource.gallery);
|
|
}
|
|
|
|
/// 从相机拍照
|
|
Future<void> pickFromCamera() async {
|
|
//if (!(await _checkPermissions(ImageSource.camera))) return;
|
|
await _pickAndProcess(ImageSource.camera);
|
|
}
|
|
|
|
/// 选择并处理图片
|
|
Future<void> _pickAndProcess(ImageSource source) async {
|
|
try {
|
|
isProcessing.value = true;
|
|
_updateStep(
|
|
'chooseImage'.tr, // 选择图片
|
|
(source == ImageSource.camera
|
|
? 'openingCamera'.tr // 正在打开相机...
|
|
: 'openingPhotoAlbum'.tr), // 正在打开相册...
|
|
ProcessingStatus.picking);
|
|
|
|
final XFile? image = await _imagePicker.pickImage(
|
|
source: source,
|
|
imageQuality: imageQuality.value,
|
|
maxWidth: maxImageSize.value.toDouble(),
|
|
maxHeight: maxImageSize.value.toDouble(),
|
|
);
|
|
|
|
if (image == null) {
|
|
_updateStep(
|
|
'cancelled'.tr, // 已取消
|
|
'userCancelledSelection'.tr, // 用户取消了选择
|
|
ProcessingStatus.idle);
|
|
return;
|
|
}
|
|
|
|
File file = File(image.path);
|
|
|
|
// 图片预处理
|
|
file = await _preprocessImage(file);
|
|
pickedImageFile.value = file;
|
|
|
|
// 直接调用图片翻译API(包含识别+回贴)
|
|
_updateStep(
|
|
'translatingImage'.tr, // 翻译图片
|
|
'callingImageTranslateService'.tr, // 正在调用图片翻译服务...
|
|
ProcessingStatus.translating,
|
|
progress: 0.6);
|
|
|
|
await _performImageTranslation(file);
|
|
|
|
// 保存历史记录
|
|
if (sourceText.value.isNotEmpty && translatedText.value.isNotEmpty) {
|
|
_updateStep(
|
|
'savingRecord'.tr, // 保存记录
|
|
'savingTranslationResult'.tr, // 正在保存翻译结果...
|
|
ProcessingStatus.saving,
|
|
progress: 0.9);
|
|
await _saveTranslationResult(file);
|
|
}
|
|
|
|
_updateStep(
|
|
'translationCompleted'.tr, // 翻译完成
|
|
'allStepsCompleted'.tr, // 所有步骤已完成
|
|
ProcessingStatus.idle,
|
|
progress: 1.0);
|
|
} catch (e) {
|
|
Logger.error('PictureTranslation', '处理失败: $e');
|
|
_updateStep(
|
|
'processingFailed'.tr, // 处理失败
|
|
e.toString(),
|
|
ProcessingStatus.error);
|
|
_showErrorDialog(
|
|
'processingFailed'.tr, // 处理失败
|
|
'imageProcessingOrTranslationFailed'.tr.replaceAll(
|
|
'{error}', e.toString())); // 图片处理或翻译失败,请重试。错误信息: {error}
|
|
} finally {
|
|
isProcessing.value = false;
|
|
}
|
|
}
|
|
|
|
/// 图片预处理
|
|
Future<File> _preprocessImage(File file) async {
|
|
try {
|
|
final bytes = await file.readAsBytes();
|
|
final image = img.decodeImage(bytes);
|
|
|
|
if (image == null) return file;
|
|
|
|
// 自动旋转图片
|
|
final orientedImage = img.bakeOrientation(image);
|
|
|
|
// 压缩图片大小(如果需要)
|
|
img.Image resizedImage = orientedImage;
|
|
if (orientedImage.width > maxImageSize.value ||
|
|
orientedImage.height > maxImageSize.value) {
|
|
resizedImage = img.copyResize(
|
|
orientedImage,
|
|
width: orientedImage.width > orientedImage.height
|
|
? maxImageSize.value
|
|
: null,
|
|
height: orientedImage.height > orientedImage.width
|
|
? maxImageSize.value
|
|
: null,
|
|
);
|
|
}
|
|
|
|
// 保存处理后的图片
|
|
final processedBytes =
|
|
img.encodeJpg(resizedImage, quality: imageQuality.value);
|
|
final processedFile = File('${file.path}_processed.jpg');
|
|
await processedFile.writeAsBytes(processedBytes);
|
|
|
|
return processedFile;
|
|
} catch (e) {
|
|
Logger.error('PictureTranslation', '图片预处理失败: $e');
|
|
return file; // 返回原图片
|
|
}
|
|
}
|
|
|
|
/// 执行图片翻译(调用火山引擎图片翻译API)
|
|
Future<void> _performImageTranslation(File imageFile) async {
|
|
try {
|
|
final bytes = await imageFile.readAsBytes();
|
|
|
|
// 图片大小限制:4MB(文档要求)
|
|
const int maxSize = 4 * 1024 * 1024;
|
|
if (bytes.lengthInBytes > maxSize) {
|
|
_updateStep(
|
|
'processingFailed'.tr, // 处理失败
|
|
'imageTooLarge'.tr, // 图片大小超过4MB限制
|
|
ProcessingStatus.error);
|
|
_showErrorDialog(
|
|
'processingFailed'.tr, // 处理失败
|
|
'imageTooLargeHint'.tr); // 图片大小超过4MB限制,请降低质量或尺寸后重试
|
|
return;
|
|
}
|
|
|
|
final targetCode = languageManager
|
|
.getAsrCodeByChineseName(targetLanguageChineseName.value) ??
|
|
'en-US';
|
|
|
|
final result = await _translationService.translateImage(
|
|
imageBytes: bytes,
|
|
targetLanguageCode: targetCode,
|
|
);
|
|
|
|
if (result == null) {
|
|
_updateStep(
|
|
'processingFailed'.tr, // 处理失败
|
|
'emptyTranslationResult'.tr, // 图片翻译接口返回空结果
|
|
ProcessingStatus.error);
|
|
_showErrorDialog(
|
|
'processingFailed'.tr, // 处理失败
|
|
'imageTranslationFailedTryLater'.tr); // 失败原因提示
|
|
return;
|
|
}
|
|
|
|
// 更新回贴图片
|
|
translatedImageBytes.value = result.translatedImageBytes != null
|
|
? Uint8List.fromList(result.translatedImageBytes!)
|
|
: null;
|
|
|
|
// 将文本块汇总为识别文本与翻译文本,便于展示与复制
|
|
if (result.blocks.isNotEmpty) {
|
|
sourceText.value = result.blocks.map((b) => b.text).join('\n');
|
|
translatedText.value =
|
|
result.blocks.map((b) => b.translation).join('\n');
|
|
} else {
|
|
sourceText.value = '';
|
|
translatedText.value = '';
|
|
}
|
|
|
|
_lastDetectedSourceLanguageCode =
|
|
_resolveDetectedSourceLanguageAsrCode(result);
|
|
} catch (e) {
|
|
Logger.error('PictureTranslation', '图片翻译调用失败: $e');
|
|
_updateStep(
|
|
'processingFailed'.tr, // 处理失败
|
|
e.toString(),
|
|
ProcessingStatus.error);
|
|
_showErrorDialog(
|
|
'processingFailed'.tr, // 处理失败
|
|
'imageTranslationApiException'.tr); // 接口调用异常
|
|
}
|
|
}
|
|
|
|
/// 保存翻译结果
|
|
Future<void> _saveTranslationResult(File imageFile) async {
|
|
try {
|
|
String sourceCode = 'zh-CN';
|
|
if (isSourceLanguageUserSet.value &&
|
|
sourceLanguageChineseName.value != 'autoDetect') {
|
|
sourceCode = languageManager
|
|
.getAsrCodeByChineseName(sourceLanguageChineseName.value) ??
|
|
'zh-CN';
|
|
} else if (_lastDetectedSourceLanguageCode != null &&
|
|
_lastDetectedSourceLanguageCode!.isNotEmpty) {
|
|
sourceCode = _lastDetectedSourceLanguageCode!;
|
|
}
|
|
|
|
final targetCode = languageManager
|
|
.getAsrCodeByChineseName(targetLanguageChineseName.value) ??
|
|
'en-US';
|
|
|
|
final history = PictureTranslationHistory(
|
|
id: DateTime.now().millisecondsSinceEpoch.toString(),
|
|
imagePath: imageFile.path,
|
|
sourceText: sourceText.value,
|
|
translatedText: translatedText.value,
|
|
sourceLanguageCode: sourceCode,
|
|
targetLanguageCode: targetCode,
|
|
timestamp: DateTime.now(),
|
|
);
|
|
|
|
_addToHistory(history);
|
|
} catch (e) {
|
|
Logger.error('PictureTranslation', '保存翻译结果失败: $e');
|
|
}
|
|
}
|
|
|
|
/// 显示错误对话框
|
|
void _showErrorDialog(String title, String message) {
|
|
Get.dialog(
|
|
AlertDialog(
|
|
title: Text(title),
|
|
content: Text(message),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Get.back(),
|
|
child: Text('confirm'.tr), // 确认
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// 设置源语言
|
|
void setSourceLanguage(String language) {
|
|
if (sourceLanguageChineseName.value != language) {
|
|
sourceLanguageChineseName.value = language;
|
|
isSourceLanguageUserSet.value = language != 'autoDetect';
|
|
_saveSettings();
|
|
}
|
|
}
|
|
|
|
/// 设置目标语言
|
|
void setTargetLanguage(String language) {
|
|
if (targetLanguageChineseName.value != language) {
|
|
if (language == 'autoDetect') return;
|
|
targetLanguageChineseName.value = language;
|
|
isTargetLanguageUserSet.value = true;
|
|
_saveSettings();
|
|
}
|
|
}
|
|
|
|
/// 交换语言
|
|
void swapLanguages() {
|
|
if (sourceLanguageChineseName.value == 'autoDetect' ||
|
|
targetLanguageChineseName.value == 'autoDetect') {
|
|
return;
|
|
}
|
|
|
|
final temp = sourceLanguageChineseName.value;
|
|
sourceLanguageChineseName.value = targetLanguageChineseName.value;
|
|
targetLanguageChineseName.value = temp;
|
|
|
|
// 交换文本
|
|
final tempText = sourceText.value;
|
|
sourceText.value = translatedText.value;
|
|
translatedText.value = tempText;
|
|
|
|
isSourceLanguageUserSet.value = true;
|
|
isTargetLanguageUserSet.value = true;
|
|
_saveSettings();
|
|
}
|
|
|
|
/// 复制文本
|
|
void copyText(String text) {
|
|
if (text.isNotEmpty) {
|
|
// 这里可以添加复制到剪切板的逻辑
|
|
Get.snackbar(
|
|
'success'.tr, // 成功
|
|
'copied'.tr); // 已复制
|
|
}
|
|
}
|
|
|
|
/// 重置状态
|
|
void reset() {
|
|
sourceText.value = '';
|
|
translatedText.value = '';
|
|
translatedImageBytes.value = null;
|
|
pickedImageFile.value = null;
|
|
_updateStep(
|
|
'waitingForAction'.tr, // 等待操作
|
|
'pleaseSelectImageToStart'.tr, // 请选择图片开始翻译
|
|
ProcessingStatus.idle);
|
|
}
|
|
|
|
String? _getLanguageByAppLocale() {
|
|
final Locale? locale = Get.locale ?? Get.deviceLocale;
|
|
if (locale == null) return null;
|
|
|
|
final languageCode = locale.languageCode.toLowerCase();
|
|
final countryCode = locale.countryCode?.toUpperCase();
|
|
final scriptCode = locale.scriptCode;
|
|
|
|
if (languageCode == 'zh') {
|
|
if (countryCode == 'HK' || countryCode == 'MO') {
|
|
return languageManager.getChineseNameByAsrCode('zh-HK');
|
|
}
|
|
if (countryCode == 'TW') {
|
|
return languageManager.getChineseNameByAsrCode('zh-TW');
|
|
}
|
|
if (scriptCode == 'Hant') {
|
|
return languageManager.getChineseNameByAsrCode('zh-TW');
|
|
}
|
|
return languageManager.getChineseNameByAsrCode('zh-CN');
|
|
}
|
|
|
|
final normalizedShortCode = switch (languageCode) {
|
|
'nb' => 'no',
|
|
_ => languageCode,
|
|
};
|
|
|
|
return languageManager.getChineseNameByShortCode(normalizedShortCode);
|
|
}
|
|
|
|
String? _resolveDetectedSourceLanguageAsrCode(ImageTranslationResult result) {
|
|
final shortCode = result.detectedLanguage ??
|
|
(result.blocks.isNotEmpty
|
|
? result.blocks.first.detectedLanguage
|
|
: null);
|
|
if (shortCode == null || shortCode.isEmpty) return null;
|
|
return languageManager.getAsrCodeByShortCode(shortCode);
|
|
}
|
|
}
|
|
|