Browse Source
添加图片翻译和文本翻译的完整功能实现,包括: 1. 新增翻译模块的路由和页面绑定 2. 实现图片翻译的控制器、模型和视图 3. 实现文本翻译的控制器和视图 4. 添加多语言支持 5. 完善翻译历史记录功能newdev_shunjiawei
14 changed files with 2561 additions and 44 deletions
@ -0,0 +1,14 @@ |
|||||
|
import 'package:get/get.dart'; |
||||
|
|
||||
|
import '../controllers/picture_translation_controller.dart'; |
||||
|
|
||||
|
class PictureTranslationBinding extends Bindings { |
||||
|
@override |
||||
|
void dependencies() { |
||||
|
if (!Get.isRegistered<PictureTranslationController>()) { |
||||
|
Get.lazyPut<PictureTranslationController>(() => PictureTranslationController()); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
|
||||
@ -0,0 +1,401 @@ |
|||||
|
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 = '中文(简体)'.obs; |
||||
|
final targetLanguageChineseName = '英语'.obs; |
||||
|
final sourceText = ''.obs; |
||||
|
final translatedText = ''.obs; |
||||
|
final isProcessing = false.obs; |
||||
|
final pickedImageFile = Rx<File?>(null); |
||||
|
final currentStep = ProcessingStep( |
||||
|
title: '等待操作', |
||||
|
description: '请选择图片开始翻译', |
||||
|
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'; |
||||
|
|
||||
|
@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) { |
||||
|
sourceLanguageChineseName.value = |
||||
|
settings['sourceLanguage'] ?? '中文(简体)'; |
||||
|
targetLanguageChineseName.value = settings['targetLanguage'] ?? '英语'; |
||||
|
imageQuality.value = settings['imageQuality'] ?? 85; |
||||
|
maxImageSize.value = settings['maxImageSize'] ?? 1920; |
||||
|
} |
||||
|
} catch (e) { |
||||
|
Logger.error('PictureTranslation', '加载设置失败: $e'); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 保存设置 |
||||
|
void _saveSettings() { |
||||
|
try { |
||||
|
_storage.write(_settingsKey, { |
||||
|
'sourceLanguage': sourceLanguageChineseName.value, |
||||
|
'targetLanguage': targetLanguageChineseName.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('权限需求', '需要相机权限才能拍照'); |
||||
|
return false; |
||||
|
} |
||||
|
} else { |
||||
|
final status = await Permission.photos.request(); |
||||
|
if (!status.isGranted) { |
||||
|
Get.snackbar('权限需求', '需要相册权限才能选择图片'); |
||||
|
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( |
||||
|
'选择图片', |
||||
|
'正在打开${source == ImageSource.camera ? '相机' : '相册'}...', |
||||
|
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('已取消', '用户取消了选择', ProcessingStatus.idle); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
File file = File(image.path); |
||||
|
|
||||
|
// 图片预处理 |
||||
|
file = await _preprocessImage(file); |
||||
|
pickedImageFile.value = file; |
||||
|
|
||||
|
_updateStep('识别文本', '正在分析图片中的文本...', ProcessingStatus.recognizing, |
||||
|
progress: 0.3); |
||||
|
|
||||
|
// 文本识别 |
||||
|
// final inputImage = InputImage.fromFile(file); |
||||
|
// final RecognizedText recognizedText = |
||||
|
// await _textRecognizer.processImage(inputImage); |
||||
|
|
||||
|
// sourceText.value = recognizedText.text.trim(); |
||||
|
|
||||
|
if (sourceText.value.isEmpty) { |
||||
|
_updateStep('识别完成', '未识别到任何文本', ProcessingStatus.idle); |
||||
|
translatedText.value = ''; |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
_updateStep('翻译文本', '正在翻译识别的文本...', ProcessingStatus.translating, |
||||
|
progress: 0.7); |
||||
|
|
||||
|
// 执行翻译 |
||||
|
await _performTranslation(); |
||||
|
|
||||
|
// 保存历史记录 |
||||
|
if (sourceText.value.isNotEmpty && translatedText.value.isNotEmpty) { |
||||
|
_updateStep('保存记录', '正在保存翻译结果...', ProcessingStatus.saving, |
||||
|
progress: 0.9); |
||||
|
await _saveTranslationResult(file); |
||||
|
} |
||||
|
|
||||
|
_updateStep('翻译完成', '所有步骤已完成', ProcessingStatus.idle, progress: 1.0); |
||||
|
} catch (e) { |
||||
|
Logger.error('PictureTranslation', '处理失败: $e'); |
||||
|
_updateStep('处理失败', e.toString(), ProcessingStatus.error); |
||||
|
_showErrorDialog('处理失败', '图片处理或翻译失败,请重试。\n错误信息: $e'); |
||||
|
} 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; // 返回原图片 |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 执行翻译 |
||||
|
Future<void> _performTranslation() async { |
||||
|
final sourceCode = languageManager |
||||
|
.getAsrCodeByChineseName(sourceLanguageChineseName.value) ?? |
||||
|
'zh-CN'; |
||||
|
final targetCode = languageManager |
||||
|
.getAsrCodeByChineseName(targetLanguageChineseName.value) ?? |
||||
|
'en-US'; |
||||
|
|
||||
|
final result = await _translationService.translateText( |
||||
|
text: sourceText.value, |
||||
|
sourceLanguageCode: sourceCode, |
||||
|
targetLanguageCode: targetCode, |
||||
|
); |
||||
|
|
||||
|
translatedText.value = result ?? ''; |
||||
|
} |
||||
|
|
||||
|
/// 保存翻译结果 |
||||
|
Future<void> _saveTranslationResult(File imageFile) async { |
||||
|
try { |
||||
|
final sourceCode = languageManager |
||||
|
.getAsrCodeByChineseName(sourceLanguageChineseName.value) ?? |
||||
|
'zh-CN'; |
||||
|
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: const Text('确定'), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
/// 设置源语言 |
||||
|
void setSourceLanguage(String language) { |
||||
|
if (sourceLanguageChineseName.value != language) { |
||||
|
sourceLanguageChineseName.value = language; |
||||
|
_saveSettings(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 设置目标语言 |
||||
|
void setTargetLanguage(String language) { |
||||
|
if (targetLanguageChineseName.value != language) { |
||||
|
targetLanguageChineseName.value = language; |
||||
|
_saveSettings(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 交换语言 |
||||
|
void swapLanguages() { |
||||
|
final temp = sourceLanguageChineseName.value; |
||||
|
sourceLanguageChineseName.value = targetLanguageChineseName.value; |
||||
|
targetLanguageChineseName.value = temp; |
||||
|
|
||||
|
// 交换文本 |
||||
|
final tempText = sourceText.value; |
||||
|
sourceText.value = translatedText.value; |
||||
|
translatedText.value = tempText; |
||||
|
|
||||
|
_saveSettings(); |
||||
|
} |
||||
|
|
||||
|
/// 复制文本 |
||||
|
void copyText(String text) { |
||||
|
if (text.isNotEmpty) { |
||||
|
// 这里可以添加复制到剪切板的逻辑 |
||||
|
Get.snackbar('成功', '文本已复制到剪切板'); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 重置状态 |
||||
|
void reset() { |
||||
|
sourceText.value = ''; |
||||
|
translatedText.value = ''; |
||||
|
pickedImageFile.value = null; |
||||
|
_updateStep('等待操作', '请选择图片开始翻译', ProcessingStatus.idle); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,134 @@ |
|||||
|
import 'package:get/get.dart'; |
||||
|
import '../../../data/services/language_manager.dart'; |
||||
|
|
||||
|
/// 图片翻译历史记录模型 |
||||
|
class PictureTranslationHistory { |
||||
|
final String id; |
||||
|
final String imagePath; |
||||
|
final String sourceText; |
||||
|
final String translatedText; |
||||
|
final String sourceLanguageCode; |
||||
|
final String targetLanguageCode; |
||||
|
final DateTime timestamp; |
||||
|
final String? thumbnailPath; // 缩略图路径 |
||||
|
|
||||
|
PictureTranslationHistory({ |
||||
|
required this.id, |
||||
|
required this.imagePath, |
||||
|
required this.sourceText, |
||||
|
required this.translatedText, |
||||
|
required this.sourceLanguageCode, |
||||
|
required this.targetLanguageCode, |
||||
|
required this.timestamp, |
||||
|
this.thumbnailPath, |
||||
|
}); |
||||
|
|
||||
|
/// 从JSON创建实例 |
||||
|
factory PictureTranslationHistory.fromJson(Map<String, dynamic> json) { |
||||
|
return PictureTranslationHistory( |
||||
|
id: json['id'] ?? '', |
||||
|
imagePath: json['imagePath'] ?? '', |
||||
|
sourceText: json['sourceText'] ?? '', |
||||
|
translatedText: json['translatedText'] ?? '', |
||||
|
sourceLanguageCode: json['sourceLanguageCode'] ?? 'zh-CN', |
||||
|
targetLanguageCode: json['targetLanguageCode'] ?? 'en-US', |
||||
|
timestamp: |
||||
|
DateTime.parse(json['timestamp'] ?? DateTime.now().toIso8601String()), |
||||
|
thumbnailPath: json['thumbnailPath'], |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
/// 转换为JSON |
||||
|
Map<String, dynamic> toJson() { |
||||
|
return { |
||||
|
'id': id, |
||||
|
'imagePath': imagePath, |
||||
|
'sourceText': sourceText, |
||||
|
'translatedText': translatedText, |
||||
|
'sourceLanguageCode': sourceLanguageCode, |
||||
|
'targetLanguageCode': targetLanguageCode, |
||||
|
'timestamp': timestamp.toIso8601String(), |
||||
|
'thumbnailPath': thumbnailPath, |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
/// 获取源语言中文名称 |
||||
|
String get sourceLanguageName { |
||||
|
final languageManager = Get.find<LanguageManager>(); |
||||
|
return languageManager.getChineseNameByAsrCode(sourceLanguageCode) ?? |
||||
|
'未知语言'; |
||||
|
} |
||||
|
|
||||
|
/// 获取目标语言中文名称 |
||||
|
String get targetLanguageName { |
||||
|
final languageManager = Get.find<LanguageManager>(); |
||||
|
return languageManager.getChineseNameByAsrCode(targetLanguageCode) ?? |
||||
|
'未知语言'; |
||||
|
} |
||||
|
|
||||
|
/// 创建副本 |
||||
|
PictureTranslationHistory copyWith({ |
||||
|
String? id, |
||||
|
String? imagePath, |
||||
|
String? sourceText, |
||||
|
String? translatedText, |
||||
|
String? sourceLanguageCode, |
||||
|
String? targetLanguageCode, |
||||
|
DateTime? timestamp, |
||||
|
String? thumbnailPath, |
||||
|
}) { |
||||
|
return PictureTranslationHistory( |
||||
|
id: id ?? this.id, |
||||
|
imagePath: imagePath ?? this.imagePath, |
||||
|
sourceText: sourceText ?? this.sourceText, |
||||
|
translatedText: translatedText ?? this.translatedText, |
||||
|
sourceLanguageCode: sourceLanguageCode ?? this.sourceLanguageCode, |
||||
|
targetLanguageCode: targetLanguageCode ?? this.targetLanguageCode, |
||||
|
timestamp: timestamp ?? this.timestamp, |
||||
|
thumbnailPath: thumbnailPath ?? this.thumbnailPath, |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 处理状态枚举 |
||||
|
enum ProcessingStatus { |
||||
|
idle, // 空闲 |
||||
|
picking, // 选择图片 |
||||
|
recognizing, // 识别文本 |
||||
|
translating, // 翻译文本 |
||||
|
saving, // 保存记录 |
||||
|
error, // 错误状态 |
||||
|
} |
||||
|
|
||||
|
/// 处理步骤信息 |
||||
|
class ProcessingStep { |
||||
|
final String title; |
||||
|
final String description; |
||||
|
final ProcessingStatus status; |
||||
|
final double progress; |
||||
|
final String? error; |
||||
|
|
||||
|
ProcessingStep({ |
||||
|
required this.title, |
||||
|
required this.description, |
||||
|
required this.status, |
||||
|
this.progress = 0.0, |
||||
|
this.error, |
||||
|
}); |
||||
|
|
||||
|
ProcessingStep copyWith({ |
||||
|
String? title, |
||||
|
String? description, |
||||
|
ProcessingStatus? status, |
||||
|
double? progress, |
||||
|
String? error, |
||||
|
}) { |
||||
|
return ProcessingStep( |
||||
|
title: title ?? this.title, |
||||
|
description: description ?? this.description, |
||||
|
status: status ?? this.status, |
||||
|
progress: progress ?? this.progress, |
||||
|
error: error ?? this.error, |
||||
|
); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,299 @@ |
|||||
|
import 'dart:io'; |
||||
|
|
||||
|
import 'package:flutter/material.dart'; |
||||
|
import 'package:flutter/services.dart'; |
||||
|
import 'package:get/get.dart'; |
||||
|
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
||||
|
|
||||
|
import '../models/picture_translation_models.dart'; |
||||
|
|
||||
|
class PictureTranslationHistoryDetailView extends StatelessWidget { |
||||
|
final PictureTranslationHistory item; |
||||
|
|
||||
|
const PictureTranslationHistoryDetailView({ |
||||
|
super.key, |
||||
|
required this.item, |
||||
|
}); |
||||
|
|
||||
|
@override |
||||
|
Widget build(BuildContext context) { |
||||
|
return Scaffold( |
||||
|
appBar: AppBar( |
||||
|
leading: IconButton( |
||||
|
icon: Icon( |
||||
|
Icons.arrow_back_ios, |
||||
|
size: 20.w, |
||||
|
color: Colors.black87, |
||||
|
), |
||||
|
onPressed: () => Get.back(), |
||||
|
), |
||||
|
centerTitle: true, |
||||
|
title: const Text('翻译详情'), |
||||
|
backgroundColor: Colors.white, |
||||
|
foregroundColor: Colors.black87, |
||||
|
elevation: 0, |
||||
|
// actions: [ |
||||
|
// IconButton( |
||||
|
// icon: const Icon(Icons.share), |
||||
|
// onPressed: _shareTranslation, |
||||
|
// tooltip: '分享', |
||||
|
// ), |
||||
|
// ], |
||||
|
), |
||||
|
body: SafeArea( |
||||
|
child: SingleChildScrollView( |
||||
|
padding: EdgeInsets.all(16.w), |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
_buildImageSection(), |
||||
|
SizedBox(height: 16.h), |
||||
|
_buildLanguageInfo(), |
||||
|
SizedBox(height: 16.h), |
||||
|
_buildTextSection('识别结果', item.sourceText, Colors.blue), |
||||
|
SizedBox(height: 16.h), |
||||
|
_buildTextSection('翻译结果', item.translatedText, Colors.green), |
||||
|
SizedBox(height: 16.h), |
||||
|
_buildTimeInfo(), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
Widget _buildImageSection() { |
||||
|
return Container( |
||||
|
decoration: BoxDecoration( |
||||
|
color: Colors.white, |
||||
|
borderRadius: BorderRadius.circular(12.r), |
||||
|
boxShadow: [ |
||||
|
BoxShadow( |
||||
|
color: Colors.black.withOpacity(0.05), |
||||
|
blurRadius: 10, |
||||
|
offset: const Offset(0, 2), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Padding( |
||||
|
padding: EdgeInsets.all(16.w), |
||||
|
child: Text( |
||||
|
'原图片', |
||||
|
style: TextStyle( |
||||
|
fontSize: 16.sp, |
||||
|
fontWeight: FontWeight.w600, |
||||
|
color: Colors.black87, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
Container( |
||||
|
height: 250.h, |
||||
|
margin: |
||||
|
EdgeInsets.symmetric(horizontal: 16.w).copyWith(bottom: 16.h), |
||||
|
decoration: BoxDecoration( |
||||
|
borderRadius: BorderRadius.circular(8.r), |
||||
|
border: Border.all(color: Colors.grey[300]!), |
||||
|
), |
||||
|
child: ClipRRect( |
||||
|
borderRadius: BorderRadius.circular(8.r), |
||||
|
child: Image.file( |
||||
|
File(item.imagePath), |
||||
|
fit: BoxFit.contain, |
||||
|
width: double.infinity, |
||||
|
errorBuilder: (context, error, stackTrace) { |
||||
|
return Center( |
||||
|
child: Column( |
||||
|
mainAxisAlignment: MainAxisAlignment.center, |
||||
|
children: [ |
||||
|
Icon(Icons.error_outline, |
||||
|
size: 48.sp, color: Colors.red[400]), |
||||
|
SizedBox(height: 8.h), |
||||
|
Text( |
||||
|
'图片加载失败', |
||||
|
style: TextStyle( |
||||
|
fontSize: 14.sp, color: Colors.red[600]), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
}, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
Widget _buildLanguageInfo() { |
||||
|
return Container( |
||||
|
padding: EdgeInsets.all(16.w), |
||||
|
decoration: BoxDecoration( |
||||
|
color: Colors.white, |
||||
|
borderRadius: BorderRadius.circular(12.r), |
||||
|
boxShadow: [ |
||||
|
BoxShadow( |
||||
|
color: Colors.black.withOpacity(0.05), |
||||
|
blurRadius: 10, |
||||
|
offset: const Offset(0, 2), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
child: Row( |
||||
|
children: [ |
||||
|
Expanded( |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Text( |
||||
|
'源语言', |
||||
|
style: TextStyle(fontSize: 12.sp, color: Colors.grey[600]), |
||||
|
), |
||||
|
SizedBox(height: 4.h), |
||||
|
Text( |
||||
|
item.sourceLanguageName, |
||||
|
style: TextStyle( |
||||
|
fontSize: 16.sp, |
||||
|
fontWeight: FontWeight.w600, |
||||
|
color: Colors.blue, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
Icon(Icons.arrow_forward, color: Colors.grey[400]), |
||||
|
Expanded( |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.end, |
||||
|
children: [ |
||||
|
Text( |
||||
|
'目标语言', |
||||
|
style: TextStyle(fontSize: 12.sp, color: Colors.grey[600]), |
||||
|
), |
||||
|
SizedBox(height: 4.h), |
||||
|
Text( |
||||
|
item.targetLanguageName, |
||||
|
style: TextStyle( |
||||
|
fontSize: 16.sp, |
||||
|
fontWeight: FontWeight.w600, |
||||
|
color: Colors.green, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
Widget _buildTextSection(String title, String content, Color color) { |
||||
|
return Container( |
||||
|
padding: EdgeInsets.all(16.w), |
||||
|
decoration: BoxDecoration( |
||||
|
color: Colors.white, |
||||
|
borderRadius: BorderRadius.circular(12.r), |
||||
|
boxShadow: [ |
||||
|
BoxShadow( |
||||
|
color: Colors.black.withOpacity(0.05), |
||||
|
blurRadius: 10, |
||||
|
offset: const Offset(0, 2), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Row( |
||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
||||
|
children: [ |
||||
|
Text( |
||||
|
title, |
||||
|
style: TextStyle( |
||||
|
fontSize: 16.sp, |
||||
|
fontWeight: FontWeight.w600, |
||||
|
color: color, |
||||
|
), |
||||
|
), |
||||
|
IconButton( |
||||
|
icon: Icon(Icons.copy, size: 20.sp, color: color), |
||||
|
onPressed: () { |
||||
|
Clipboard.setData(ClipboardData(text: content)); |
||||
|
Get.snackbar('成功', '已复制到剪切板'); |
||||
|
}, |
||||
|
padding: EdgeInsets.zero, |
||||
|
constraints: const BoxConstraints(), |
||||
|
tooltip: '复制', |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
SizedBox(height: 12.h), |
||||
|
SelectableText( |
||||
|
content, |
||||
|
style: TextStyle( |
||||
|
fontSize: 15.sp, |
||||
|
color: Colors.black87, |
||||
|
height: 1.5, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
Widget _buildTimeInfo() { |
||||
|
return Container( |
||||
|
padding: EdgeInsets.all(16.w), |
||||
|
decoration: BoxDecoration( |
||||
|
color: Colors.white, |
||||
|
borderRadius: BorderRadius.circular(12.r), |
||||
|
boxShadow: [ |
||||
|
BoxShadow( |
||||
|
color: Colors.black.withOpacity(0.05), |
||||
|
blurRadius: 10, |
||||
|
offset: const Offset(0, 2), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Text( |
||||
|
'翻译时间', |
||||
|
style: TextStyle( |
||||
|
fontSize: 16.sp, |
||||
|
fontWeight: FontWeight.w600, |
||||
|
color: Colors.black87, |
||||
|
), |
||||
|
), |
||||
|
SizedBox(height: 8.h), |
||||
|
Text( |
||||
|
_formatDateTime(item.timestamp), |
||||
|
style: TextStyle( |
||||
|
fontSize: 14.sp, |
||||
|
color: Colors.grey[600], |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
String _formatDateTime(DateTime dateTime) { |
||||
|
return '${dateTime.year}年${dateTime.month}月${dateTime.day}日 ' |
||||
|
'${dateTime.hour.toString().padLeft(2, '0')}:' |
||||
|
'${dateTime.minute.toString().padLeft(2, '0')}'; |
||||
|
} |
||||
|
|
||||
|
void _shareTranslation() { |
||||
|
final shareText = '原文:${item.sourceText}\n\n' |
||||
|
'译文:${item.translatedText}\n\n' |
||||
|
'翻译方向:${item.sourceLanguageName} → ${item.targetLanguageName}'; |
||||
|
|
||||
|
// 这里可以调用分享插件 |
||||
|
Get.snackbar('提示', '分享功能开发中'); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,917 @@ |
|||||
|
import 'dart:io'; |
||||
|
|
||||
|
import 'package:flutter/material.dart'; |
||||
|
import 'package:flutter/services.dart'; |
||||
|
import 'package:get/get.dart'; |
||||
|
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
||||
|
import 'package:image_picker/image_picker.dart'; |
||||
|
|
||||
|
import '../controllers/picture_translation_controller.dart'; |
||||
|
import '../models/picture_translation_models.dart'; |
||||
|
import 'picture_translation_history_detail_view.dart'; |
||||
|
|
||||
|
class PictureTranslationView extends GetView<PictureTranslationController> { |
||||
|
const PictureTranslationView({super.key}); |
||||
|
|
||||
|
@override |
||||
|
Widget build(BuildContext context) { |
||||
|
return Scaffold( |
||||
|
backgroundColor: Colors.grey[50], |
||||
|
appBar: _buildAppBar(), |
||||
|
body: SafeArea( |
||||
|
child: Obx(() { |
||||
|
return CustomScrollView( |
||||
|
slivers: [ |
||||
|
SliverPadding( |
||||
|
padding: EdgeInsets.all(16.w), |
||||
|
sliver: SliverList( |
||||
|
delegate: SliverChildListDelegate([ |
||||
|
_buildQuickActions(), |
||||
|
SizedBox(height: 16.h), |
||||
|
_buildLanguageSelector(), |
||||
|
SizedBox(height: 16.h), |
||||
|
_buildImageSection(), |
||||
|
SizedBox(height: 16.h), |
||||
|
if (controller.currentStep.value.status != |
||||
|
ProcessingStatus.idle) |
||||
|
_buildProcessingIndicator(), |
||||
|
if (controller.sourceText.value.isNotEmpty || |
||||
|
controller.translatedText.value.isNotEmpty) ...[ |
||||
|
SizedBox(height: 16.h), |
||||
|
_buildResultsSection(), |
||||
|
], |
||||
|
SizedBox(height: 16.h), |
||||
|
_buildHistorySection(), |
||||
|
]), |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
); |
||||
|
}), |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
/// 构建应用栏 |
||||
|
PreferredSizeWidget _buildAppBar() { |
||||
|
return AppBar( |
||||
|
leading: IconButton( |
||||
|
icon: Icon( |
||||
|
Icons.arrow_back_ios, |
||||
|
size: 20.w, |
||||
|
color: Colors.black87, |
||||
|
), |
||||
|
onPressed: () => Get.back(), |
||||
|
), |
||||
|
centerTitle: true, |
||||
|
title: const Text('图片翻译'), |
||||
|
backgroundColor: Colors.white, |
||||
|
foregroundColor: Colors.black87, |
||||
|
elevation: 0, |
||||
|
actions: [ |
||||
|
IconButton( |
||||
|
icon: const Icon(Icons.history), |
||||
|
onPressed: _showHistoryBottomSheet, |
||||
|
tooltip: '历史记录', |
||||
|
), |
||||
|
PopupMenuButton<String>( |
||||
|
onSelected: _handleMenuAction, |
||||
|
itemBuilder: (context) => [ |
||||
|
const PopupMenuItem( |
||||
|
value: 'clear', |
||||
|
child: Row( |
||||
|
children: [ |
||||
|
Icon(Icons.clear_all, size: 20), |
||||
|
SizedBox(width: 8), |
||||
|
Text('清空结果'), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
const PopupMenuItem( |
||||
|
value: 'settings', |
||||
|
child: Row( |
||||
|
children: [ |
||||
|
Icon(Icons.settings, size: 20), |
||||
|
SizedBox(width: 8), |
||||
|
Text('设置'), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
], |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
/// 构建快速操作区域 |
||||
|
Widget _buildQuickActions() { |
||||
|
return Container( |
||||
|
padding: EdgeInsets.all(16.w), |
||||
|
decoration: BoxDecoration( |
||||
|
color: Colors.white, |
||||
|
borderRadius: BorderRadius.circular(12.r), |
||||
|
boxShadow: [ |
||||
|
BoxShadow( |
||||
|
color: Colors.black.withOpacity(0.05), |
||||
|
blurRadius: 10, |
||||
|
offset: const Offset(0, 2), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Text( |
||||
|
'选择图片来源', |
||||
|
style: TextStyle( |
||||
|
fontSize: 16.sp, |
||||
|
fontWeight: FontWeight.w600, |
||||
|
color: Colors.black87, |
||||
|
), |
||||
|
), |
||||
|
SizedBox(height: 12.h), |
||||
|
Row( |
||||
|
children: [ |
||||
|
Expanded( |
||||
|
child: _buildActionButton( |
||||
|
icon: Icons.photo_library, |
||||
|
label: '相册', |
||||
|
onTap: controller.pickFromGallery, |
||||
|
color: Colors.blue, |
||||
|
), |
||||
|
), |
||||
|
SizedBox(width: 12.w), |
||||
|
Expanded( |
||||
|
child: _buildActionButton( |
||||
|
icon: Icons.camera_alt, |
||||
|
label: '拍照', |
||||
|
onTap: controller.pickFromCamera, |
||||
|
color: Colors.green, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
/// 构建操作按钮 |
||||
|
Widget _buildActionButton({ |
||||
|
required IconData icon, |
||||
|
required String label, |
||||
|
required VoidCallback onTap, |
||||
|
required Color color, |
||||
|
}) { |
||||
|
return Material( |
||||
|
color: color.withOpacity(0.1), |
||||
|
borderRadius: BorderRadius.circular(8.r), |
||||
|
child: InkWell( |
||||
|
onTap: controller.isProcessing.value ? null : onTap, |
||||
|
borderRadius: BorderRadius.circular(8.r), |
||||
|
child: Container( |
||||
|
padding: EdgeInsets.symmetric(vertical: 16.h), |
||||
|
child: Column( |
||||
|
children: [ |
||||
|
Icon( |
||||
|
icon, |
||||
|
color: color, |
||||
|
size: 24.sp, |
||||
|
), |
||||
|
SizedBox(height: 4.h), |
||||
|
Text( |
||||
|
label, |
||||
|
style: TextStyle( |
||||
|
fontSize: 14.sp, |
||||
|
fontWeight: FontWeight.w500, |
||||
|
color: color, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
/// 构建语言选择器 |
||||
|
Widget _buildLanguageSelector() { |
||||
|
return Container( |
||||
|
padding: EdgeInsets.all(16.w), |
||||
|
decoration: BoxDecoration( |
||||
|
color: Colors.white, |
||||
|
borderRadius: BorderRadius.circular(12.r), |
||||
|
boxShadow: [ |
||||
|
BoxShadow( |
||||
|
color: Colors.black.withOpacity(0.05), |
||||
|
blurRadius: 10, |
||||
|
offset: const Offset(0, 2), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Text( |
||||
|
'语言设置', |
||||
|
style: TextStyle( |
||||
|
fontSize: 16.sp, |
||||
|
fontWeight: FontWeight.w600, |
||||
|
color: Colors.black87, |
||||
|
), |
||||
|
), |
||||
|
SizedBox(height: 12.h), |
||||
|
Row( |
||||
|
children: [ |
||||
|
Expanded( |
||||
|
child: _buildLangTile( |
||||
|
'源语言', |
||||
|
controller.sourceLanguageChineseName.value, |
||||
|
() => _showLangDialog(true), |
||||
|
Colors.blue, |
||||
|
), |
||||
|
), |
||||
|
SizedBox(width: 8.w), |
||||
|
IconButton( |
||||
|
onPressed: controller.swapLanguages, |
||||
|
icon: const Icon(Icons.swap_horiz), |
||||
|
tooltip: '交换语言', |
||||
|
), |
||||
|
SizedBox(width: 8.w), |
||||
|
Expanded( |
||||
|
child: _buildLangTile( |
||||
|
'目标语言', |
||||
|
controller.targetLanguageChineseName.value, |
||||
|
() => _showLangDialog(false), |
||||
|
Colors.green, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
Widget _buildLangTile( |
||||
|
String label, String value, VoidCallback onTap, Color color) { |
||||
|
return InkWell( |
||||
|
onTap: onTap, |
||||
|
borderRadius: BorderRadius.circular(8.r), |
||||
|
child: Container( |
||||
|
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 12.h), |
||||
|
decoration: BoxDecoration( |
||||
|
borderRadius: BorderRadius.circular(8.r), |
||||
|
border: Border.all(color: color.withOpacity(0.3)), |
||||
|
color: color.withOpacity(0.05), |
||||
|
), |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Text( |
||||
|
label, |
||||
|
style: TextStyle(fontSize: 12.sp, color: Colors.grey[600]), |
||||
|
), |
||||
|
SizedBox(height: 4.h), |
||||
|
Text( |
||||
|
value, |
||||
|
style: TextStyle( |
||||
|
fontSize: 14.sp, |
||||
|
fontWeight: FontWeight.w600, |
||||
|
color: color, |
||||
|
), |
||||
|
maxLines: 1, |
||||
|
overflow: TextOverflow.ellipsis, |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
Future<void> _showLangDialog(bool isSource) async { |
||||
|
final langs = controller.languageManager.getAllChineseNames(); |
||||
|
final selected = await Get.dialog<String>( |
||||
|
SimpleDialog( |
||||
|
title: Text(isSource ? '选择源语言' : '选择目标语言'), |
||||
|
children: [ |
||||
|
SizedBox( |
||||
|
width: 320.w, |
||||
|
height: 420.h, |
||||
|
child: ListView.builder( |
||||
|
itemCount: langs.length, |
||||
|
itemBuilder: (_, i) => ListTile( |
||||
|
title: Text(langs[i]), |
||||
|
onTap: () => Get.back(result: langs[i]), |
||||
|
), |
||||
|
), |
||||
|
) |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
if (selected != null) { |
||||
|
if (isSource) { |
||||
|
controller.setSourceLanguage(selected); |
||||
|
} else { |
||||
|
controller.setTargetLanguage(selected); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
Widget _buildImageSection() { |
||||
|
return Container( |
||||
|
decoration: BoxDecoration( |
||||
|
color: Colors.white, |
||||
|
borderRadius: BorderRadius.circular(12.r), |
||||
|
boxShadow: [ |
||||
|
BoxShadow( |
||||
|
color: Colors.black.withOpacity(0.05), |
||||
|
blurRadius: 10, |
||||
|
offset: const Offset(0, 2), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Padding( |
||||
|
padding: EdgeInsets.all(16.w), |
||||
|
child: Text( |
||||
|
'图片预览', |
||||
|
style: TextStyle( |
||||
|
fontSize: 16.sp, |
||||
|
fontWeight: FontWeight.w600, |
||||
|
color: Colors.black87, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
_buildImagePreview(), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
Widget _buildProcessingIndicator() { |
||||
|
final step = controller.currentStep.value; |
||||
|
return Container( |
||||
|
padding: EdgeInsets.all(16.w), |
||||
|
decoration: BoxDecoration( |
||||
|
color: Colors.white, |
||||
|
borderRadius: BorderRadius.circular(12.r), |
||||
|
boxShadow: [ |
||||
|
BoxShadow( |
||||
|
color: Colors.black.withOpacity(0.05), |
||||
|
blurRadius: 10, |
||||
|
offset: const Offset(0, 2), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Row( |
||||
|
children: [ |
||||
|
_buildStatusIcon(step.status), |
||||
|
SizedBox(width: 12.w), |
||||
|
Expanded( |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Text( |
||||
|
step.title, |
||||
|
style: TextStyle( |
||||
|
fontSize: 16.sp, |
||||
|
fontWeight: FontWeight.w600, |
||||
|
color: Colors.black87, |
||||
|
), |
||||
|
), |
||||
|
if (step.description.isNotEmpty) ...[ |
||||
|
SizedBox(height: 4.h), |
||||
|
Text( |
||||
|
step.description, |
||||
|
style: |
||||
|
TextStyle(fontSize: 14.sp, color: Colors.grey[600]), |
||||
|
), |
||||
|
], |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
if (step.progress > 0 && step.status != ProcessingStatus.error) ...[ |
||||
|
SizedBox(height: 12.h), |
||||
|
LinearProgressIndicator( |
||||
|
value: step.progress, |
||||
|
backgroundColor: Colors.grey[200], |
||||
|
valueColor: |
||||
|
AlwaysStoppedAnimation<Color>(_getStatusColor(step.status)), |
||||
|
), |
||||
|
], |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
Widget _buildStatusIcon(ProcessingStatus status) { |
||||
|
switch (status) { |
||||
|
case ProcessingStatus.picking: |
||||
|
case ProcessingStatus.recognizing: |
||||
|
case ProcessingStatus.translating: |
||||
|
case ProcessingStatus.saving: |
||||
|
return SizedBox( |
||||
|
width: 20.w, |
||||
|
height: 20.w, |
||||
|
child: CircularProgressIndicator( |
||||
|
strokeWidth: 2, |
||||
|
color: _getStatusColor(status), |
||||
|
), |
||||
|
); |
||||
|
case ProcessingStatus.error: |
||||
|
return Icon(Icons.error_outline, color: Colors.red, size: 20.sp); |
||||
|
case ProcessingStatus.idle: |
||||
|
return Icon(Icons.check_circle_outline, |
||||
|
color: Colors.green, size: 20.sp); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
Color _getStatusColor(ProcessingStatus status) { |
||||
|
switch (status) { |
||||
|
case ProcessingStatus.picking: |
||||
|
return Colors.blue; |
||||
|
case ProcessingStatus.recognizing: |
||||
|
return Colors.orange; |
||||
|
case ProcessingStatus.translating: |
||||
|
return Colors.purple; |
||||
|
case ProcessingStatus.saving: |
||||
|
return Colors.green; |
||||
|
case ProcessingStatus.error: |
||||
|
return Colors.red; |
||||
|
case ProcessingStatus.idle: |
||||
|
return Colors.grey; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
Widget _buildResultsSection() { |
||||
|
return Container( |
||||
|
decoration: BoxDecoration( |
||||
|
color: Colors.white, |
||||
|
borderRadius: BorderRadius.circular(12.r), |
||||
|
boxShadow: [ |
||||
|
BoxShadow( |
||||
|
color: Colors.black.withOpacity(0.05), |
||||
|
blurRadius: 10, |
||||
|
offset: const Offset(0, 2), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Padding( |
||||
|
padding: EdgeInsets.all(16.w), |
||||
|
child: Text( |
||||
|
'翻译结果', |
||||
|
style: TextStyle( |
||||
|
fontSize: 16.sp, |
||||
|
fontWeight: FontWeight.w600, |
||||
|
color: Colors.black87, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
if (controller.sourceText.value.isNotEmpty) |
||||
|
_buildTextCard( |
||||
|
'识别结果', |
||||
|
controller.sourceText.value, |
||||
|
controller.sourceLanguageChineseName.value, |
||||
|
Colors.blue, |
||||
|
), |
||||
|
if (controller.translatedText.value.isNotEmpty) |
||||
|
_buildTextCard( |
||||
|
'翻译结果', |
||||
|
controller.translatedText.value, |
||||
|
controller.targetLanguageChineseName.value, |
||||
|
Colors.green, |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
Widget _buildTextCard( |
||||
|
String title, String content, String language, Color color) { |
||||
|
return Container( |
||||
|
margin: EdgeInsets.symmetric(horizontal: 16.w).copyWith(bottom: 16.h), |
||||
|
padding: EdgeInsets.all(16.w), |
||||
|
decoration: BoxDecoration( |
||||
|
color: color.withOpacity(0.05), |
||||
|
borderRadius: BorderRadius.circular(8.r), |
||||
|
border: Border.all(color: color.withOpacity(0.2)), |
||||
|
), |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Row( |
||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
||||
|
children: [ |
||||
|
Text( |
||||
|
'$title ($language)', |
||||
|
style: TextStyle( |
||||
|
fontSize: 14.sp, |
||||
|
fontWeight: FontWeight.w600, |
||||
|
color: color, |
||||
|
), |
||||
|
), |
||||
|
IconButton( |
||||
|
icon: Icon(Icons.copy, size: 18.sp, color: color), |
||||
|
onPressed: () { |
||||
|
Clipboard.setData(ClipboardData(text: content)); |
||||
|
Get.snackbar('成功', '已复制到剪切板'); |
||||
|
}, |
||||
|
padding: EdgeInsets.zero, |
||||
|
constraints: const BoxConstraints(), |
||||
|
tooltip: '复制', |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
SizedBox(height: 8.h), |
||||
|
SelectableText( |
||||
|
content, |
||||
|
style: |
||||
|
TextStyle(fontSize: 15.sp, color: Colors.black87, height: 1.4), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
Widget _buildHistorySection() { |
||||
|
return Container( |
||||
|
decoration: BoxDecoration( |
||||
|
color: Colors.white, |
||||
|
borderRadius: BorderRadius.circular(12.r), |
||||
|
boxShadow: [ |
||||
|
BoxShadow( |
||||
|
color: Colors.black.withOpacity(0.05), |
||||
|
blurRadius: 10, |
||||
|
offset: const Offset(0, 2), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Padding( |
||||
|
padding: EdgeInsets.all(16.w), |
||||
|
child: Row( |
||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
||||
|
children: [ |
||||
|
Text( |
||||
|
'最近翻译', |
||||
|
style: TextStyle( |
||||
|
fontSize: 16.sp, |
||||
|
fontWeight: FontWeight.w600, |
||||
|
color: Colors.black87, |
||||
|
), |
||||
|
), |
||||
|
TextButton( |
||||
|
onPressed: _showHistoryBottomSheet, |
||||
|
child: const Text('查看全部'), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
if (controller.historyList.isEmpty) |
||||
|
Padding( |
||||
|
padding: |
||||
|
EdgeInsets.symmetric(horizontal: 16.w).copyWith(bottom: 16.h), |
||||
|
child: Text( |
||||
|
'暂无翻译记录', |
||||
|
style: TextStyle(fontSize: 14.sp, color: Colors.grey[600]), |
||||
|
), |
||||
|
) |
||||
|
else |
||||
|
...controller.historyList |
||||
|
.take(3) |
||||
|
.map((item) => _buildHistoryItem(item)) |
||||
|
.toList(), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
Widget _buildHistoryItem(PictureTranslationHistory item) { |
||||
|
return ListTile( |
||||
|
leading: ClipRRect( |
||||
|
borderRadius: BorderRadius.circular(4.r), |
||||
|
child: Container( |
||||
|
width: 40.w, |
||||
|
height: 40.w, |
||||
|
color: Colors.grey[200], |
||||
|
child: Icon(Icons.image, color: Colors.grey[400]), |
||||
|
), |
||||
|
), |
||||
|
title: Text( |
||||
|
item.sourceText, |
||||
|
maxLines: 1, |
||||
|
overflow: TextOverflow.ellipsis, |
||||
|
style: TextStyle(fontSize: 14.sp), |
||||
|
), |
||||
|
subtitle: Text( |
||||
|
'${item.sourceLanguageName} → ${item.targetLanguageName}', |
||||
|
style: TextStyle(fontSize: 12.sp, color: Colors.grey[600]), |
||||
|
), |
||||
|
trailing: Text( |
||||
|
_formatTime(item.timestamp), |
||||
|
style: TextStyle(fontSize: 12.sp, color: Colors.grey[500]), |
||||
|
), |
||||
|
onTap: () => _showHistoryDetail(item), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
void _showHistoryBottomSheet() { |
||||
|
Get.bottomSheet( |
||||
|
Container( |
||||
|
height: 600.h, |
||||
|
decoration: BoxDecoration( |
||||
|
color: Colors.white, |
||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20.r)), |
||||
|
), |
||||
|
child: Column( |
||||
|
children: [ |
||||
|
Container( |
||||
|
padding: EdgeInsets.all(16.w), |
||||
|
child: Row( |
||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
||||
|
children: [ |
||||
|
Text( |
||||
|
'翻译历史', |
||||
|
style: |
||||
|
TextStyle(fontSize: 18.sp, fontWeight: FontWeight.bold), |
||||
|
), |
||||
|
IconButton( |
||||
|
onPressed: () => Get.back(), |
||||
|
icon: const Icon(Icons.close), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
const Divider(height: 1), |
||||
|
Expanded( |
||||
|
child: Obx(() { |
||||
|
if (controller.historyList.isEmpty) { |
||||
|
return Center( |
||||
|
child: Text( |
||||
|
'暂无翻译记录', |
||||
|
style: |
||||
|
TextStyle(fontSize: 16.sp, color: Colors.grey[600]), |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
return ListView.separated( |
||||
|
itemCount: controller.historyList.length, |
||||
|
separatorBuilder: (context, index) => |
||||
|
const Divider(height: 1), |
||||
|
itemBuilder: (context, index) { |
||||
|
final item = controller.historyList[index]; |
||||
|
return _buildHistoryItem(item); |
||||
|
}, |
||||
|
); |
||||
|
}), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
isScrollControlled: true, |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
void _handleMenuAction(String action) { |
||||
|
switch (action) { |
||||
|
case 'clear': |
||||
|
controller.reset(); |
||||
|
break; |
||||
|
case 'settings': |
||||
|
_showSettingsDialog(); |
||||
|
break; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
void _showHistoryDetail(PictureTranslationHistory item) { |
||||
|
Get.to(() => PictureTranslationHistoryDetailView(item: item)); |
||||
|
} |
||||
|
|
||||
|
String _formatTime(DateTime timestamp) { |
||||
|
final now = DateTime.now(); |
||||
|
final difference = now.difference(timestamp); |
||||
|
|
||||
|
if (difference.inDays > 0) { |
||||
|
return '${difference.inDays}天前'; |
||||
|
} else if (difference.inHours > 0) { |
||||
|
return '${difference.inHours}小时前'; |
||||
|
} else if (difference.inMinutes > 0) { |
||||
|
return '${difference.inMinutes}分钟前'; |
||||
|
} else { |
||||
|
return '刚刚'; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
void _showSettingsDialog() { |
||||
|
Get.dialog( |
||||
|
AlertDialog( |
||||
|
title: const Text('设置'), |
||||
|
content: Column( |
||||
|
mainAxisSize: MainAxisSize.min, |
||||
|
children: [ |
||||
|
ListTile( |
||||
|
leading: const Icon(Icons.photo_size_select_actual), |
||||
|
title: const Text('图片质量'), |
||||
|
subtitle: |
||||
|
Obx(() => Text('当前: ${controller.imageQuality.value}%')), |
||||
|
onTap: () { |
||||
|
Get.back(); |
||||
|
_showImageQualityDialog(); |
||||
|
}, |
||||
|
), |
||||
|
ListTile( |
||||
|
leading: const Icon(Icons.photo_size_select_large), |
||||
|
title: const Text('最大尺寸'), |
||||
|
subtitle: |
||||
|
Obx(() => Text('当前: ${controller.maxImageSize.value}px')), |
||||
|
onTap: () { |
||||
|
Get.back(); |
||||
|
_showImageSizeDialog(); |
||||
|
}, |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
actions: [ |
||||
|
TextButton( |
||||
|
onPressed: () => Get.back(), |
||||
|
child: const Text('关闭'), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
void _showImageQualityDialog() { |
||||
|
Get.dialog( |
||||
|
AlertDialog( |
||||
|
title: const Text('设置图片质量'), |
||||
|
content: Obx(() => Column( |
||||
|
mainAxisSize: MainAxisSize.min, |
||||
|
children: [ |
||||
|
Text('当前质量: ${controller.imageQuality.value}%'), |
||||
|
Slider( |
||||
|
value: controller.imageQuality.value.toDouble(), |
||||
|
min: 50, |
||||
|
max: 100, |
||||
|
divisions: 10, |
||||
|
onChanged: (value) { |
||||
|
controller.imageQuality.value = value.toInt(); |
||||
|
}, |
||||
|
), |
||||
|
], |
||||
|
)), |
||||
|
actions: [ |
||||
|
TextButton( |
||||
|
onPressed: () => Get.back(), |
||||
|
child: const Text('确定'), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
void _showImageSizeDialog() { |
||||
|
final sizes = [1280, 1920, 2560, 3840]; |
||||
|
Get.dialog( |
||||
|
AlertDialog( |
||||
|
title: const Text('设置最大尺寸'), |
||||
|
content: Column( |
||||
|
mainAxisSize: MainAxisSize.min, |
||||
|
children: sizes |
||||
|
.map((size) => RadioListTile<int>( |
||||
|
title: Text('${size}px'), |
||||
|
value: size, |
||||
|
groupValue: controller.maxImageSize.value, |
||||
|
onChanged: (value) { |
||||
|
if (value != null) { |
||||
|
controller.maxImageSize.value = value; |
||||
|
Get.back(); |
||||
|
} |
||||
|
}, |
||||
|
)) |
||||
|
.toList(), |
||||
|
), |
||||
|
actions: [ |
||||
|
TextButton( |
||||
|
onPressed: () => Get.back(), |
||||
|
child: const Text('取消'), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
void _showClearHistoryDialog() { |
||||
|
Get.dialog( |
||||
|
AlertDialog( |
||||
|
title: const Text('清空历史'), |
||||
|
content: const Text('确定要清空所有翻译历史吗?此操作不可撤销。'), |
||||
|
actions: [ |
||||
|
TextButton( |
||||
|
onPressed: () => Get.back(), |
||||
|
child: const Text('取消'), |
||||
|
), |
||||
|
TextButton( |
||||
|
onPressed: () { |
||||
|
controller.clearHistory(); |
||||
|
Get.back(); |
||||
|
}, |
||||
|
child: Text('确定', style: TextStyle(color: Colors.red)), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
Widget _buildImagePreview() { |
||||
|
final file = controller.pickedImageFile.value; |
||||
|
return Container( |
||||
|
height: 200.h, |
||||
|
margin: EdgeInsets.symmetric(horizontal: 16.w).copyWith(bottom: 16.h), |
||||
|
decoration: BoxDecoration( |
||||
|
color: Colors.grey[100], |
||||
|
borderRadius: BorderRadius.circular(8.r), |
||||
|
border: Border.all(color: Colors.grey[300]!), |
||||
|
), |
||||
|
child: file == null |
||||
|
? Center( |
||||
|
child: Column( |
||||
|
mainAxisAlignment: MainAxisAlignment.center, |
||||
|
children: [ |
||||
|
Icon(Icons.image_outlined, |
||||
|
size: 48.sp, color: Colors.grey[400]), |
||||
|
SizedBox(height: 8.h), |
||||
|
Text( |
||||
|
'请选择图片', |
||||
|
style: TextStyle(fontSize: 14.sp, color: Colors.grey[600]), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
) |
||||
|
: ClipRRect( |
||||
|
borderRadius: BorderRadius.circular(8.r), |
||||
|
child: Image.file( |
||||
|
file, |
||||
|
fit: BoxFit.cover, |
||||
|
width: double.infinity, |
||||
|
errorBuilder: (context, error, stackTrace) { |
||||
|
return Center( |
||||
|
child: Column( |
||||
|
mainAxisAlignment: MainAxisAlignment.center, |
||||
|
children: [ |
||||
|
Icon(Icons.error_outline, |
||||
|
size: 48.sp, color: Colors.red[400]), |
||||
|
SizedBox(height: 8.h), |
||||
|
Text( |
||||
|
'图片加载失败', |
||||
|
style: TextStyle( |
||||
|
fontSize: 14.sp, color: Colors.red[600]), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
}, |
||||
|
), |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
Widget _buildSourceTextBox() { |
||||
|
return _buildTextBox('识别结果', controller.sourceText.value); |
||||
|
} |
||||
|
|
||||
|
Widget _buildTranslatedTextBox() { |
||||
|
return _buildTextBox('翻译结果', controller.translatedText.value); |
||||
|
} |
||||
|
|
||||
|
Widget _buildTextBox(String title, String content) { |
||||
|
return Container( |
||||
|
padding: EdgeInsets.all(12.w), |
||||
|
decoration: BoxDecoration( |
||||
|
borderRadius: BorderRadius.circular(8.r), |
||||
|
border: Border.all(color: Colors.grey.shade300), |
||||
|
), |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Text(title, style: const TextStyle(fontWeight: FontWeight.bold)), |
||||
|
SizedBox(height: 8.h), |
||||
|
Text(content.isEmpty ? '—' : content), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,11 @@ |
|||||
|
import 'package:get/get.dart'; |
||||
|
import '../controllers/text_translation_controller.dart'; |
||||
|
|
||||
|
/// 文本翻译页面依赖绑定 |
||||
|
class TextTranslationBinding extends Bindings { |
||||
|
@override |
||||
|
void dependencies() { |
||||
|
// 注册文本翻译控制器 |
||||
|
Get.lazyPut<TextTranslationController>(() => TextTranslationController()); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,336 @@ |
|||||
|
import 'package:flutter/material.dart'; |
||||
|
import 'package:flutter/services.dart'; |
||||
|
import 'package:get/get.dart'; |
||||
|
import 'package:get_storage/get_storage.dart'; |
||||
|
|
||||
|
import '../../../data/services/volcano_translation_service.dart'; |
||||
|
import '../../../data/services/language_manager.dart'; |
||||
|
import '../../../core/utils/logger.dart'; |
||||
|
|
||||
|
/// 文本翻译控制器 |
||||
|
class TextTranslationController extends GetxController { |
||||
|
// 依赖服务 |
||||
|
final VolcanoTranslationService _translationService = Get.find<VolcanoTranslationService>(); |
||||
|
final LanguageManager _languageManager = Get.find<LanguageManager>(); |
||||
|
final GetStorage _storage = GetStorage(); |
||||
|
|
||||
|
// 文本输入控制器 |
||||
|
final TextEditingController textController = TextEditingController(); |
||||
|
|
||||
|
// 响应式状态 |
||||
|
final sourceLanguageChineseName = '中文(简体)'.obs; |
||||
|
final targetLanguageChineseName = '英语'.obs; |
||||
|
final sourceLanguageCode = 'zh-CN'.obs; |
||||
|
final targetLanguageCode = 'en-US'.obs; |
||||
|
final translatedText = ''.obs; |
||||
|
final isTranslating = false.obs; |
||||
|
final inputText = ''.obs; |
||||
|
|
||||
|
// 支持的语言列表 |
||||
|
List<String> get supportedLanguages => _languageManager.getAllChineseNames(); |
||||
|
|
||||
|
@override |
||||
|
void onInit() { |
||||
|
super.onInit(); |
||||
|
|
||||
|
// 初始化翻译服务 |
||||
|
_initializeTranslationService(); |
||||
|
|
||||
|
_loadSavedLanguages(); |
||||
|
|
||||
|
// 监听文本输入变化 |
||||
|
textController.addListener(() { |
||||
|
inputText.value = textController.text; |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
/// 初始化翻译服务 |
||||
|
Future<void> _initializeTranslationService() async { |
||||
|
try { |
||||
|
final initialized = await _translationService.initialize(); |
||||
|
if (!initialized) { |
||||
|
Logger.error('翻译服务初始化失败'); |
||||
|
Get.snackbar( |
||||
|
'error'.tr, |
||||
|
'翻译服务初始化失败,请检查网络连接', |
||||
|
snackPosition: SnackPosition.TOP, |
||||
|
backgroundColor: Colors.red.withOpacity(0.9), |
||||
|
colorText: Colors.white, |
||||
|
); |
||||
|
} else { |
||||
|
Logger.info('翻译服务初始化成功'); |
||||
|
} |
||||
|
} catch (e) { |
||||
|
Logger.error('翻译服务初始化异常: $e'); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
void onClose() { |
||||
|
textController.dispose(); |
||||
|
super.onClose(); |
||||
|
} |
||||
|
|
||||
|
/// 加载保存的语言设置 |
||||
|
void _loadSavedLanguages() { |
||||
|
try { |
||||
|
final savedSourceLanguage = _storage.read('text_translation_source_language'); |
||||
|
final savedTargetLanguage = _storage.read('text_translation_target_language'); |
||||
|
|
||||
|
if (savedSourceLanguage != null && supportedLanguages.contains(savedSourceLanguage)) { |
||||
|
sourceLanguageChineseName.value = savedSourceLanguage; |
||||
|
// 同时更新语言代码 |
||||
|
final asrCode = _languageManager.getAsrCodeByChineseName(savedSourceLanguage); |
||||
|
if (asrCode != null) { |
||||
|
sourceLanguageCode.value = asrCode; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if (savedTargetLanguage != null && supportedLanguages.contains(savedTargetLanguage)) { |
||||
|
targetLanguageChineseName.value = savedTargetLanguage; |
||||
|
// 同时更新语言代码 |
||||
|
final asrCode = _languageManager.getAsrCodeByChineseName(savedTargetLanguage); |
||||
|
if (asrCode != null) { |
||||
|
targetLanguageCode.value = asrCode; |
||||
|
} |
||||
|
} |
||||
|
} catch (e) { |
||||
|
Logger.error('加载语言设置失败: $e'); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 保存语言设置 |
||||
|
void _saveLanguageSettings() { |
||||
|
try { |
||||
|
_storage.write('text_translation_source_language', sourceLanguageChineseName.value); |
||||
|
_storage.write('text_translation_target_language', targetLanguageChineseName.value); |
||||
|
} catch (e) { |
||||
|
Logger.error('保存语言设置失败: $e'); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 设置源语言 |
||||
|
void setSourceLanguage(String language) { |
||||
|
if (language != sourceLanguageChineseName.value) { |
||||
|
sourceLanguageChineseName.value = language; |
||||
|
|
||||
|
// 同时更新语言代码 |
||||
|
final asrCode = _languageManager.getAsrCodeByChineseName(language); |
||||
|
if (asrCode != null) { |
||||
|
sourceLanguageCode.value = asrCode; |
||||
|
} |
||||
|
|
||||
|
_saveLanguageSettings(); |
||||
|
|
||||
|
// 如果已有翻译结果,清空结果 |
||||
|
if (translatedText.value.isNotEmpty) { |
||||
|
translatedText.value = ''; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 设置目标语言 |
||||
|
void setTargetLanguage(String language) { |
||||
|
if (language != targetLanguageChineseName.value) { |
||||
|
targetLanguageChineseName.value = language; |
||||
|
|
||||
|
// 同时更新语言代码 |
||||
|
final asrCode = _languageManager.getAsrCodeByChineseName(language); |
||||
|
if (asrCode != null) { |
||||
|
targetLanguageCode.value = asrCode; |
||||
|
} |
||||
|
|
||||
|
_saveLanguageSettings(); |
||||
|
|
||||
|
// 如果已有翻译结果,清空结果 |
||||
|
if (translatedText.value.isNotEmpty) { |
||||
|
translatedText.value = ''; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 交换源语言和目标语言 |
||||
|
void swapLanguages() { |
||||
|
final tempSource = sourceLanguageChineseName.value; |
||||
|
final tempTarget = targetLanguageChineseName.value; |
||||
|
final tempSourceCode = sourceLanguageCode.value; |
||||
|
final tempTargetCode = targetLanguageCode.value; |
||||
|
final tempInputText = textController.text; |
||||
|
final tempTranslatedText = translatedText.value; |
||||
|
|
||||
|
sourceLanguageChineseName.value = tempTarget; |
||||
|
targetLanguageChineseName.value = tempSource; |
||||
|
sourceLanguageCode.value = tempTargetCode; |
||||
|
targetLanguageCode.value = tempSourceCode; |
||||
|
|
||||
|
// 交换输入文本和翻译结果 |
||||
|
textController.text = tempTranslatedText; |
||||
|
translatedText.value = tempInputText; |
||||
|
|
||||
|
_saveLanguageSettings(); |
||||
|
} |
||||
|
|
||||
|
/// 执行翻译 |
||||
|
Future<void> translateText() async { |
||||
|
final text = textController.text.trim(); |
||||
|
|
||||
|
// 验证输入 |
||||
|
if (text.isEmpty) { |
||||
|
Get.snackbar( |
||||
|
'error'.tr, |
||||
|
'pleaseInputText'.tr, |
||||
|
snackPosition: SnackPosition.TOP, |
||||
|
backgroundColor: Colors.orange.withOpacity(0.9), |
||||
|
colorText: Colors.white, |
||||
|
); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
if (text.length > 1000) { |
||||
|
Get.snackbar( |
||||
|
'error'.tr, |
||||
|
'textTooLong'.tr, |
||||
|
snackPosition: SnackPosition.TOP, |
||||
|
backgroundColor: Colors.orange.withOpacity(0.9), |
||||
|
colorText: Colors.white, |
||||
|
); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
// 检查源语言和目标语言是否相同 |
||||
|
if (sourceLanguageChineseName.value == targetLanguageChineseName.value) { |
||||
|
translatedText.value = text; |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
isTranslating.value = true; |
||||
|
translatedText.value = ''; |
||||
|
|
||||
|
try { |
||||
|
Logger.info('开始翻译: $text'); |
||||
|
Logger.info('源语言: ${sourceLanguageChineseName.value} -> ${sourceLanguageCode.value}'); |
||||
|
Logger.info('目标语言: ${targetLanguageChineseName.value} -> ${targetLanguageCode.value}'); |
||||
|
|
||||
|
// 确保翻译服务已初始化 |
||||
|
Logger.info('确保翻译服务已初始化...'); |
||||
|
final initialized = await _translationService.initialize(); |
||||
|
Logger.info('翻译服务初始化结果: $initialized'); |
||||
|
if (!initialized) { |
||||
|
throw Exception('翻译服务初始化失败'); |
||||
|
} |
||||
|
|
||||
|
Logger.info('调用翻译服务...'); |
||||
|
|
||||
|
// 调用翻译服务,直接使用维护的语言代码 |
||||
|
final result = await _translationService.translateText( |
||||
|
text: text, |
||||
|
sourceLanguageCode: sourceLanguageCode.value, |
||||
|
targetLanguageCode: targetLanguageCode.value, |
||||
|
); |
||||
|
|
||||
|
Logger.info('翻译服务返回结果: $result'); |
||||
|
|
||||
|
if (result != null && result.isNotEmpty) { |
||||
|
translatedText.value = result; |
||||
|
Logger.info('翻译成功: $result'); |
||||
|
} else { |
||||
|
Logger.error('翻译服务返回空结果'); |
||||
|
throw Exception('翻译服务返回空结果'); |
||||
|
} |
||||
|
} catch (e) { |
||||
|
Logger.error('翻译失败: $e'); |
||||
|
Get.snackbar( |
||||
|
'error'.tr, |
||||
|
'translationFailed'.tr, |
||||
|
snackPosition: SnackPosition.TOP, |
||||
|
backgroundColor: Colors.red.withOpacity(0.9), |
||||
|
colorText: Colors.white, |
||||
|
); |
||||
|
} finally { |
||||
|
isTranslating.value = false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 复制翻译结果 |
||||
|
void copyResult() { |
||||
|
if (translatedText.value.isNotEmpty) { |
||||
|
Clipboard.setData(ClipboardData(text: translatedText.value)); |
||||
|
Get.snackbar( |
||||
|
'success'.tr, |
||||
|
'copied'.tr, |
||||
|
snackPosition: SnackPosition.TOP, |
||||
|
backgroundColor: Colors.green.withOpacity(0.9), |
||||
|
colorText: Colors.white, |
||||
|
duration: const Duration(seconds: 2), |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 清空输入 |
||||
|
void clearInput() { |
||||
|
textController.clear(); |
||||
|
translatedText.value = ''; |
||||
|
} |
||||
|
|
||||
|
/// 显示语言选择对话框 |
||||
|
void showLanguageSelector({required bool isSourceLanguage}) { |
||||
|
Get.bottomSheet( |
||||
|
Container( |
||||
|
height: Get.height * 0.6, |
||||
|
decoration: const BoxDecoration( |
||||
|
color: Colors.white, |
||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)), |
||||
|
), |
||||
|
child: Column( |
||||
|
children: [ |
||||
|
Container( |
||||
|
padding: const EdgeInsets.all(16), |
||||
|
child: Row( |
||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
||||
|
children: [ |
||||
|
Text( |
||||
|
isSourceLanguage ? 'sourceLanguage'.tr : 'targetLanguage'.tr, |
||||
|
style: const TextStyle( |
||||
|
fontSize: 18, |
||||
|
fontWeight: FontWeight.bold, |
||||
|
), |
||||
|
), |
||||
|
IconButton( |
||||
|
onPressed: () => Get.back(), |
||||
|
icon: const Icon(Icons.close), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
const Divider(height: 1), |
||||
|
Expanded( |
||||
|
child: ListView.builder( |
||||
|
itemCount: supportedLanguages.length, |
||||
|
itemBuilder: (context, index) { |
||||
|
final language = supportedLanguages[index]; |
||||
|
final isSelected = isSourceLanguage |
||||
|
? language == sourceLanguageChineseName.value |
||||
|
: language == targetLanguageChineseName.value; |
||||
|
|
||||
|
return ListTile( |
||||
|
title: Text(language), |
||||
|
trailing: isSelected ? const Icon(Icons.check, color: Colors.blue) : null, |
||||
|
onTap: () { |
||||
|
if (isSourceLanguage) { |
||||
|
setSourceLanguage(language); |
||||
|
} else { |
||||
|
setTargetLanguage(language); |
||||
|
} |
||||
|
Get.back(); |
||||
|
}, |
||||
|
); |
||||
|
}, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
isScrollControlled: true, |
||||
|
); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,395 @@ |
|||||
|
import 'package:flutter/material.dart'; |
||||
|
import 'package:get/get.dart'; |
||||
|
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
||||
|
|
||||
|
import '../controllers/text_translation_controller.dart'; |
||||
|
|
||||
|
/// 文本翻译页面 |
||||
|
class TextTranslationView extends GetView<TextTranslationController> { |
||||
|
const TextTranslationView({Key? key}) : super(key: key); |
||||
|
|
||||
|
@override |
||||
|
Widget build(BuildContext context) { |
||||
|
final isDarkMode = Theme.of(context).brightness == Brightness.dark; |
||||
|
|
||||
|
return Scaffold( |
||||
|
backgroundColor: isDarkMode ? Colors.black : Colors.grey[50], |
||||
|
appBar: _buildAppBar(isDarkMode), |
||||
|
body: SafeArea( |
||||
|
child: SingleChildScrollView( |
||||
|
padding: EdgeInsets.all(16.w), |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
// 语言选择区域 |
||||
|
_buildLanguageSelector(isDarkMode), |
||||
|
SizedBox(height: 20.h), |
||||
|
|
||||
|
// 输入区域 |
||||
|
_buildInputSection(isDarkMode), |
||||
|
SizedBox(height: 20.h), |
||||
|
|
||||
|
// 翻译按钮 |
||||
|
_buildTranslateButton(isDarkMode), |
||||
|
SizedBox(height: 20.h), |
||||
|
|
||||
|
// 翻译结果区域 |
||||
|
_buildResultSection(isDarkMode), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
/// 构建应用栏 |
||||
|
PreferredSizeWidget _buildAppBar(bool isDarkMode) { |
||||
|
return AppBar( |
||||
|
backgroundColor: isDarkMode ? Colors.black : Colors.white, |
||||
|
elevation: 0, |
||||
|
leading: IconButton( |
||||
|
icon: Icon( |
||||
|
Icons.arrow_back, |
||||
|
color: isDarkMode ? Colors.white : Colors.black, |
||||
|
), |
||||
|
onPressed: () => Get.back(), |
||||
|
), |
||||
|
title: Text( |
||||
|
'textTranslation'.tr, |
||||
|
style: TextStyle( |
||||
|
color: isDarkMode ? Colors.white : Colors.black, |
||||
|
fontSize: 18.sp, |
||||
|
fontWeight: FontWeight.w600, |
||||
|
), |
||||
|
), |
||||
|
actions: [ |
||||
|
IconButton( |
||||
|
icon: Icon( |
||||
|
Icons.clear_all, |
||||
|
color: isDarkMode ? Colors.white : Colors.black, |
||||
|
), |
||||
|
onPressed: controller.clearInput, |
||||
|
tooltip: 'clearInput'.tr, |
||||
|
), |
||||
|
], |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
/// 构建语言选择器 |
||||
|
Widget _buildLanguageSelector(bool isDarkMode) { |
||||
|
return Container( |
||||
|
padding: EdgeInsets.all(16.w), |
||||
|
decoration: BoxDecoration( |
||||
|
color: isDarkMode ? Colors.grey[900] : Colors.white, |
||||
|
borderRadius: BorderRadius.circular(12.r), |
||||
|
boxShadow: [ |
||||
|
BoxShadow( |
||||
|
color: Colors.black.withOpacity(0.05), |
||||
|
blurRadius: 10, |
||||
|
offset: const Offset(0, 2), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
child: Column( |
||||
|
children: [ |
||||
|
// 源语言选择 |
||||
|
Obx(() => _buildLanguageButton( |
||||
|
label: 'sourceLanguage'.tr, |
||||
|
selectedLanguage: controller.sourceLanguageChineseName.value, |
||||
|
onTap: () => controller.showLanguageSelector(isSourceLanguage: true), |
||||
|
isDarkMode: isDarkMode, |
||||
|
)), |
||||
|
|
||||
|
SizedBox(height: 12.h), |
||||
|
|
||||
|
// 交换语言按钮 |
||||
|
Center( |
||||
|
child: GestureDetector( |
||||
|
onTap: controller.swapLanguages, |
||||
|
child: Container( |
||||
|
width: 40.w, |
||||
|
height: 40.w, |
||||
|
decoration: BoxDecoration( |
||||
|
color: Colors.blue.withOpacity(0.1), |
||||
|
shape: BoxShape.circle, |
||||
|
), |
||||
|
child: Icon( |
||||
|
Icons.swap_vert, |
||||
|
color: Colors.blue, |
||||
|
size: 20.sp, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
|
||||
|
SizedBox(height: 12.h), |
||||
|
|
||||
|
// 目标语言选择 |
||||
|
Obx(() => _buildLanguageButton( |
||||
|
label: 'targetLanguage'.tr, |
||||
|
selectedLanguage: controller.targetLanguageChineseName.value, |
||||
|
onTap: () => controller.showLanguageSelector(isSourceLanguage: false), |
||||
|
isDarkMode: isDarkMode, |
||||
|
)), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
/// 构建语言选择按钮 |
||||
|
Widget _buildLanguageButton({ |
||||
|
required String label, |
||||
|
required String selectedLanguage, |
||||
|
required VoidCallback onTap, |
||||
|
required bool isDarkMode, |
||||
|
}) { |
||||
|
return GestureDetector( |
||||
|
onTap: onTap, |
||||
|
child: Container( |
||||
|
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 12.h), |
||||
|
decoration: BoxDecoration( |
||||
|
color: isDarkMode ? Colors.grey[800] : Colors.grey[100], |
||||
|
borderRadius: BorderRadius.circular(8.r), |
||||
|
border: Border.all( |
||||
|
color: isDarkMode ? Colors.grey[700]! : Colors.grey[300]!, |
||||
|
), |
||||
|
), |
||||
|
child: Row( |
||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
||||
|
children: [ |
||||
|
Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Text( |
||||
|
label, |
||||
|
style: TextStyle( |
||||
|
fontSize: 12.sp, |
||||
|
color: isDarkMode ? Colors.grey[400] : Colors.grey[600], |
||||
|
), |
||||
|
), |
||||
|
SizedBox(height: 4.h), |
||||
|
Text( |
||||
|
selectedLanguage, |
||||
|
style: TextStyle( |
||||
|
fontSize: 16.sp, |
||||
|
fontWeight: FontWeight.w500, |
||||
|
color: isDarkMode ? Colors.white : Colors.black, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
Icon( |
||||
|
Icons.keyboard_arrow_down, |
||||
|
color: isDarkMode ? Colors.grey[400] : Colors.grey[600], |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
/// 构建输入区域 |
||||
|
Widget _buildInputSection(bool isDarkMode) { |
||||
|
return Container( |
||||
|
decoration: BoxDecoration( |
||||
|
color: isDarkMode ? Colors.grey[900] : Colors.white, |
||||
|
borderRadius: BorderRadius.circular(12.r), |
||||
|
boxShadow: [ |
||||
|
BoxShadow( |
||||
|
color: Colors.black.withOpacity(0.05), |
||||
|
blurRadius: 10, |
||||
|
offset: const Offset(0, 2), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Padding( |
||||
|
padding: EdgeInsets.fromLTRB(16.w, 16.h, 16.w, 8.h), |
||||
|
child: Text( |
||||
|
'inputTextToTranslate'.tr, |
||||
|
style: TextStyle( |
||||
|
fontSize: 14.sp, |
||||
|
fontWeight: FontWeight.w500, |
||||
|
color: isDarkMode ? Colors.grey[300] : Colors.grey[700], |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
Container( |
||||
|
margin: EdgeInsets.symmetric(horizontal: 16.w), |
||||
|
padding: EdgeInsets.all(12.w), |
||||
|
decoration: BoxDecoration( |
||||
|
color: isDarkMode ? Colors.grey[800] : Colors.grey[50], |
||||
|
borderRadius: BorderRadius.circular(8.r), |
||||
|
border: Border.all( |
||||
|
color: isDarkMode ? Colors.grey[700]! : Colors.grey[300]!, |
||||
|
), |
||||
|
), |
||||
|
child: TextField( |
||||
|
controller: controller.textController, |
||||
|
maxLines: 6, |
||||
|
maxLength: 1000, |
||||
|
style: TextStyle( |
||||
|
fontSize: 16.sp, |
||||
|
color: isDarkMode ? Colors.white : Colors.black, |
||||
|
), |
||||
|
decoration: InputDecoration( |
||||
|
hintText: 'inputTextToTranslate'.tr, |
||||
|
hintStyle: TextStyle( |
||||
|
color: isDarkMode ? Colors.grey[500] : Colors.grey[400], |
||||
|
), |
||||
|
border: InputBorder.none, |
||||
|
counterStyle: TextStyle( |
||||
|
color: isDarkMode ? Colors.grey[500] : Colors.grey[400], |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
SizedBox(height: 16.h), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
/// 构建翻译按钮 |
||||
|
Widget _buildTranslateButton(bool isDarkMode) { |
||||
|
return Obx(() => SizedBox( |
||||
|
width: double.infinity, |
||||
|
child: ElevatedButton( |
||||
|
onPressed: controller.isTranslating.value ? null : controller.translateText, |
||||
|
style: ElevatedButton.styleFrom( |
||||
|
backgroundColor: Colors.blue, |
||||
|
foregroundColor: Colors.white, |
||||
|
padding: EdgeInsets.symmetric(vertical: 16.h), |
||||
|
shape: RoundedRectangleBorder( |
||||
|
borderRadius: BorderRadius.circular(12.r), |
||||
|
), |
||||
|
elevation: 0, |
||||
|
disabledBackgroundColor: Colors.grey, |
||||
|
), |
||||
|
child: controller.isTranslating.value |
||||
|
? Row( |
||||
|
mainAxisAlignment: MainAxisAlignment.center, |
||||
|
children: [ |
||||
|
SizedBox( |
||||
|
width: 20.w, |
||||
|
height: 20.w, |
||||
|
child: const CircularProgressIndicator( |
||||
|
strokeWidth: 2, |
||||
|
valueColor: AlwaysStoppedAnimation<Color>(Colors.white), |
||||
|
), |
||||
|
), |
||||
|
SizedBox(width: 12.w), |
||||
|
Text( |
||||
|
'translating'.tr, |
||||
|
style: TextStyle( |
||||
|
fontSize: 16.sp, |
||||
|
fontWeight: FontWeight.w600, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
) |
||||
|
: Text( |
||||
|
'translate'.tr, |
||||
|
style: TextStyle( |
||||
|
fontSize: 16.sp, |
||||
|
fontWeight: FontWeight.w600, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
)); |
||||
|
} |
||||
|
|
||||
|
/// 构建翻译结果区域 |
||||
|
Widget _buildResultSection(bool isDarkMode) { |
||||
|
return Obx(() { |
||||
|
if (controller.translatedText.value.isEmpty) { |
||||
|
return const SizedBox.shrink(); |
||||
|
} |
||||
|
|
||||
|
return Container( |
||||
|
width: double.infinity, |
||||
|
padding: EdgeInsets.all(16.w), |
||||
|
decoration: BoxDecoration( |
||||
|
color: isDarkMode ? Colors.grey[900] : Colors.white, |
||||
|
borderRadius: BorderRadius.circular(12.r), |
||||
|
boxShadow: [ |
||||
|
BoxShadow( |
||||
|
color: Colors.black.withOpacity(0.05), |
||||
|
blurRadius: 10, |
||||
|
offset: const Offset(0, 2), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Row( |
||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
||||
|
children: [ |
||||
|
Text( |
||||
|
'translationResult'.tr, |
||||
|
style: TextStyle( |
||||
|
fontSize: 14.sp, |
||||
|
fontWeight: FontWeight.w500, |
||||
|
color: isDarkMode ? Colors.grey[300] : Colors.grey[700], |
||||
|
), |
||||
|
), |
||||
|
GestureDetector( |
||||
|
onTap: controller.copyResult, |
||||
|
child: Container( |
||||
|
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 6.h), |
||||
|
decoration: BoxDecoration( |
||||
|
color: Colors.blue.withOpacity(0.1), |
||||
|
borderRadius: BorderRadius.circular(6.r), |
||||
|
), |
||||
|
child: Row( |
||||
|
mainAxisSize: MainAxisSize.min, |
||||
|
children: [ |
||||
|
Icon( |
||||
|
Icons.copy, |
||||
|
size: 16.sp, |
||||
|
color: Colors.blue, |
||||
|
), |
||||
|
SizedBox(width: 4.w), |
||||
|
Text( |
||||
|
'copyResult'.tr, |
||||
|
style: TextStyle( |
||||
|
fontSize: 12.sp, |
||||
|
color: Colors.blue, |
||||
|
fontWeight: FontWeight.w500, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
SizedBox(height: 12.h), |
||||
|
Container( |
||||
|
width: double.infinity, |
||||
|
padding: EdgeInsets.all(12.w), |
||||
|
decoration: BoxDecoration( |
||||
|
color: isDarkMode ? Colors.grey[800] : Colors.grey[50], |
||||
|
borderRadius: BorderRadius.circular(8.r), |
||||
|
border: Border.all( |
||||
|
color: isDarkMode ? Colors.grey[700]! : Colors.grey[300]!, |
||||
|
), |
||||
|
), |
||||
|
child: Text( |
||||
|
controller.translatedText.value, |
||||
|
style: TextStyle( |
||||
|
fontSize: 16.sp, |
||||
|
color: isDarkMode ? Colors.white : Colors.black, |
||||
|
height: 1.5, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue