7 changed files with 0 additions and 849 deletions
@ -1,349 +0,0 @@ |
|||||
import 'dart:async'; |
|
||||
import 'dart:convert'; |
|
||||
import 'dart:typed_data'; |
|
||||
import 'package:crypto/crypto.dart'; |
|
||||
import 'package:record/record.dart'; |
|
||||
import 'package:web_socket_channel/web_socket_channel.dart'; |
|
||||
import '../../../core/utils/logger.dart'; |
|
||||
import 'package:get/get.dart'; |
|
||||
import '../asr_service.dart'; |
|
||||
|
|
||||
/// 讯飞 语音识别服务 |
|
||||
/// |
|
||||
class XunfeiAsrService extends GetxService implements AsrService { |
|
||||
final String baseUrl = 'wss://rtasr.xfyun.cn/v1/ws'; |
|
||||
final int _chunkSize = 1280; // 每次发送的数据大小 |
|
||||
|
|
||||
final String appId = '43d87d22'; |
|
||||
final String apiKey = '83f063755e8d342670002f50a4471d42'; |
|
||||
|
|
||||
AudioRecorder? recorder; |
|
||||
Timer? _timer; |
|
||||
|
|
||||
WebSocketChannel? _channel; |
|
||||
bool isConnected = false; //是否链接 |
|
||||
|
|
||||
bool _isInitialized = false; |
|
||||
|
|
||||
final List<String> _defaultSupportedLanguages = ['zh-CN']; |
|
||||
String _languageStr = 'cn'; |
|
||||
|
|
||||
// 连续识别相关 |
|
||||
bool _isContinuousRecognitionActive = false; |
|
||||
StreamController<RecognitionEvent>? _eventStreamController; |
|
||||
|
|
||||
@override |
|
||||
List<String> get supportedLanguages => _defaultSupportedLanguages; |
|
||||
|
|
||||
Uint8List _buff = Uint8List(0); //音频缓存区 |
|
||||
BytesBuilder _originaBytes = BytesBuilder(); //原始音频 |
|
||||
bool isRecording = false; |
|
||||
|
|
||||
@override |
|
||||
Future<bool> initialize({ |
|
||||
List<String>? supportedLanguages, |
|
||||
}) async { |
|
||||
try { |
|
||||
final List<String> languages = |
|
||||
supportedLanguages ?? _defaultSupportedLanguages; |
|
||||
|
|
||||
String languagetoStr = languages.first.split('-')[1].toLowerCase(); |
|
||||
_languageStr = languagetoStr; |
|
||||
|
|
||||
_isInitialized = true; |
|
||||
Logger.info('Azure 语音识别服务初始化成功'); |
|
||||
return true; |
|
||||
} catch (e) { |
|
||||
Logger.error('Azure 语音识别服务初始化失败: ${e.toString()}'); |
|
||||
_isInitialized = false; |
|
||||
rethrow; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
@override |
|
||||
Future<RecognitionEvent> recognizeOnce() async { |
|
||||
if (!_isInitialized) { |
|
||||
await initialize(); |
|
||||
} |
|
||||
|
|
||||
try { |
|
||||
throw Exception('无效的识别结果格式'); |
|
||||
} catch (e) { |
|
||||
Logger.error('语音识别失败: ${e.toString()}'); |
|
||||
return RecognitionEvent.error(e.toString()); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// @override |
|
||||
// Future<Stream<RecognitionEvent>> startContinuousRecognition( |
|
||||
// bool audioSourceType) async { |
|
||||
// if (!_isInitialized) { |
|
||||
// await initialize(); |
|
||||
// } |
|
||||
|
|
||||
// try { |
|
||||
// _eventStreamController = StreamController<RecognitionEvent>.broadcast(); |
|
||||
|
|
||||
// await recorder?.dispose(); |
|
||||
// recorder = AudioRecorder(); |
|
||||
// Stream<Uint8List>? stream = await recorder?.startStream( |
|
||||
// const RecordConfig( |
|
||||
// sampleRate: 16000, |
|
||||
// encoder: AudioEncoder.pcm16bits, |
|
||||
// numChannels: 1, |
|
||||
// ), |
|
||||
// ); |
|
||||
// isRecording = true; |
|
||||
// stream?.listen((data) { |
|
||||
// if (isRecording) { |
|
||||
// _buff = _appendToBuffer(data); |
|
||||
// _originaBytes.add(data); |
|
||||
// } |
|
||||
// }); |
|
||||
// await _tryConnecting(); |
|
||||
// _timer = Timer.periodic(const Duration(milliseconds: 40), (timer) async { |
|
||||
// //每40毫秒读取一次数据 |
|
||||
// var frame = _getAudioData(); |
|
||||
// if (frame.isNotEmpty) { |
|
||||
// _channel?.sink.add(frame); |
|
||||
// } |
|
||||
// }); |
|
||||
|
|
||||
// _isContinuousRecognitionActive = true; |
|
||||
// return _eventStreamController!.stream; |
|
||||
// } catch (e) { |
|
||||
// Logger.error('开始连续语音识别失败: ${e.toString()}'); |
|
||||
// rethrow; |
|
||||
// } |
|
||||
// } |
|
||||
|
|
||||
//写入音频数据到缓存区中 |
|
||||
Uint8List _appendToBuffer(Uint8List newData) { |
|
||||
var newBuffer = Uint8List(_buff.length + newData.length); |
|
||||
newBuffer.setAll(0, _buff); |
|
||||
newBuffer.setAll(_buff.length, newData); |
|
||||
return newBuffer; |
|
||||
} |
|
||||
|
|
||||
//读取缓存区中一帧数据 |
|
||||
Uint8List _getAudioData() { |
|
||||
if (_buff.length >= _chunkSize) { |
|
||||
// 从缓冲区中读取1280字节的数据 |
|
||||
var data = _buff.sublist(0, _chunkSize); |
|
||||
// 移除已读取的数据 |
|
||||
_buff = _buff.sublist(_chunkSize); |
|
||||
return data; |
|
||||
} else { |
|
||||
return _buff; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
//尝试连接 |
|
||||
Future<void> _tryConnecting() async { |
|
||||
try { |
|
||||
final url = _geturl(); |
|
||||
_channel = WebSocketChannel.connect(Uri.parse(url)); |
|
||||
_channel?.stream.timeout(const Duration(seconds: 10)); //设置超时时间 |
|
||||
await _channel?.ready; |
|
||||
isConnected = true; |
|
||||
_channel?.stream.listen( |
|
||||
(message) { |
|
||||
_handle(message); |
|
||||
}, |
|
||||
onError: (error) { |
|
||||
isConnected = false; |
|
||||
Logger.error('连接失败: ${error.toString()}'); |
|
||||
}, |
|
||||
onDone: () { |
|
||||
isConnected = false; |
|
||||
Logger.error('WebSocket 连接已关闭!'); |
|
||||
}, |
|
||||
cancelOnError: true, |
|
||||
); |
|
||||
} catch (e) { |
|
||||
Logger.error("初始化异常----------------${e.toString()}"); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
//处理数据 |
|
||||
void _handle(data) { |
|
||||
try { |
|
||||
final result = jsonDecode(data); |
|
||||
if (result['code'] == "0" && result['data'] != "") { |
|
||||
final data = jsonDecode(result['data']); |
|
||||
String msg = ""; |
|
||||
String role = ''; |
|
||||
|
|
||||
List rtList = data['cn']['st']['rt']; |
|
||||
for (var rt in rtList) { |
|
||||
for (var ws in rt['ws']) { |
|
||||
for (var ws in ws['cw']) { |
|
||||
msg += ws['w']; |
|
||||
if (ws['rl'] != "0") { |
|
||||
role = ws['rl']; |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
Logger.info( |
|
||||
"识别到的内容 type:${data['cn']['st']['type']} msg:$msg rl:$role"); |
|
||||
if (msg.isNotEmpty) { |
|
||||
if (data['cn']['st']['type'] == "0") { |
|
||||
_eventStreamController?.add(RecognitionEvent( |
|
||||
type: RecognitionEventType.finalResult, |
|
||||
text: msg, |
|
||||
role: role, |
|
||||
audio: _originaBytes.toBytes(), |
|
||||
)); |
|
||||
_originaBytes.clear(); |
|
||||
} else { |
|
||||
_eventStreamController?.add(RecognitionEvent( |
|
||||
type: RecognitionEventType.intermediateResult, |
|
||||
text: msg, |
|
||||
role: role, |
|
||||
)); |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
} catch (e) { |
|
||||
Logger.error("接受消息异常 ${e.toString()}"); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
///获取链接地址 |
|
||||
String _geturl() { |
|
||||
// 生成时间戳 |
|
||||
String ts = (DateTime.now().millisecondsSinceEpoch ~/ 1000).toString(); |
|
||||
// 计算 baseString |
|
||||
String baseString = md5.convert(utf8.encode(appId + ts)).toString(); |
|
||||
// 计算 signa |
|
||||
var hmacSha1 = Hmac(sha1, utf8.encode(apiKey)); |
|
||||
var signa = hmacSha1.convert(utf8.encode(baseString)); |
|
||||
String signaBase64 = base64Encode(signa.bytes); |
|
||||
String url = ""; |
|
||||
// 构建 WebSocket URL |
|
||||
url = |
|
||||
'$baseUrl?appid=$appId&ts=$ts&lang=$_languageStr&roleType=2&signa=${Uri.encodeComponent(signaBase64)}'; |
|
||||
return url; |
|
||||
} |
|
||||
|
|
||||
@override |
|
||||
Future<bool> stopContinuousRecognition() async { |
|
||||
try { |
|
||||
await recorder?.stop(); |
|
||||
isRecording = false; |
|
||||
_timer?.cancel(); |
|
||||
_buff = Uint8List(0); |
|
||||
_originaBytes = BytesBuilder(); |
|
||||
if (isConnected) { |
|
||||
_channel?.sink.add(utf8.encode('{"end": true}')); //结束 |
|
||||
} |
|
||||
|
|
||||
_isContinuousRecognitionActive = false; |
|
||||
return true; |
|
||||
} catch (e) { |
|
||||
Logger.error('停止连续语音识别失败: ${e.toString()}'); |
|
||||
rethrow; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
@override |
|
||||
bool isContinuousRecognitionActive() { |
|
||||
return _isContinuousRecognitionActive; |
|
||||
} |
|
||||
|
|
||||
@override |
|
||||
Future<void> dispose() async { |
|
||||
try { |
|
||||
await stopContinuousRecognition(); |
|
||||
recorder?.dispose(); |
|
||||
_channel?.sink.close(); |
|
||||
|
|
||||
_eventStreamController?.close(); |
|
||||
_eventStreamController = null; |
|
||||
|
|
||||
_isInitialized = false; |
|
||||
|
|
||||
Logger.info('Azure 语音识别资源已释放'); |
|
||||
} catch (e) { |
|
||||
Logger.error('释放语音识别资源失败: ${e.toString()}'); |
|
||||
_isInitialized = false; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
@override |
|
||||
Future<bool> enableRecord(bool audioSourceType, String filePath, |
|
||||
{bool acceptAudioData = false}) { |
|
||||
// TODO: implement enableRecord |
|
||||
throw UnimplementedError(); |
|
||||
} |
|
||||
|
|
||||
@override |
|
||||
Future<bool> stopRecord(bool isSave) { |
|
||||
// TODO: implement stopRecord |
|
||||
throw UnimplementedError(); |
|
||||
} |
|
||||
|
|
||||
@override |
|
||||
Future<bool> pauseRecord() { |
|
||||
// TODO: implement pauseRecord |
|
||||
throw UnimplementedError(); |
|
||||
} |
|
||||
|
|
||||
@override |
|
||||
Future<bool> moveFile(String sourcePath, String destPath) { |
|
||||
// TODO: implement moveFile |
|
||||
throw UnimplementedError(); |
|
||||
} |
|
||||
|
|
||||
@override |
|
||||
Future<bool> renameFile(String filePath, String newName) { |
|
||||
// TODO: implement renameFile |
|
||||
throw UnimplementedError(); |
|
||||
} |
|
||||
|
|
||||
@override |
|
||||
Future<void> disableBluetoothAudio() { |
|
||||
// TODO: implement disableBluetoothAudio |
|
||||
throw UnimplementedError(); |
|
||||
} |
|
||||
|
|
||||
@override |
|
||||
Future<void> restoreOriginalAudioState() { |
|
||||
// TODO: implement restoreOriginalAudioState |
|
||||
throw UnimplementedError(); |
|
||||
} |
|
||||
|
|
||||
@override |
|
||||
Future<Stream<RecognitionEvent>> recognizeCallback() { |
|
||||
// TODO: implement recognizeCallback |
|
||||
throw UnimplementedError(); |
|
||||
} |
|
||||
|
|
||||
@override |
|
||||
Future<bool> startContinuousRecognition( |
|
||||
bool audioSourceType, { |
|
||||
bool isRemoveFirstPunctuation = true, |
|
||||
}) { |
|
||||
// TODO: implement startContinuousRecognition |
|
||||
throw UnimplementedError(); |
|
||||
} |
|
||||
|
|
||||
@override |
|
||||
Future<bool> setAudioConfig({int sampleRate = 16000, int channels = 1}) { |
|
||||
// TODO: implement setAudioConfig |
|
||||
throw UnimplementedError(); |
|
||||
} |
|
||||
|
|
||||
@override |
|
||||
Future<bool> resumeRecord() { |
|
||||
// TODO: implement resumeRecord |
|
||||
throw UnimplementedError(); |
|
||||
} |
|
||||
|
|
||||
@override |
|
||||
Stream<Uint8List>? getAudioDataStream() { |
|
||||
// TODO: implement getAudioDataStream |
|
||||
throw UnimplementedError(); |
|
||||
} |
|
||||
} |
|
||||
@ -1,10 +0,0 @@ |
|||||
import 'package:get/get.dart'; |
|
||||
import '../controllers/FTFTranslation_controller.dart'; |
|
||||
|
|
||||
class FTFTranslationBinding extends Bindings { |
|
||||
@override |
|
||||
void dependencies() { |
|
||||
// 注册实时语音聊天控制器 |
|
||||
Get.lazyPut<FTFTranslationController>(() => FTFTranslationController()); |
|
||||
} |
|
||||
} |
|
||||
@ -1,246 +0,0 @@ |
|||||
import 'dart:async'; |
|
||||
import 'dart:typed_data'; |
|
||||
|
|
||||
import 'package:get/get.dart'; |
|
||||
import '../../../core/utils/logger.dart'; |
|
||||
import '../../../data/services/asr_service.dart'; |
|
||||
import '../../../data/services/speech_impl/xunfei_asr_service.dart'; |
|
||||
import '../../../data/services/tts_service.dart'; |
|
||||
import '../../../data/services/language_manager.dart'; |
|
||||
import '../../../data/services/volcano_translation_service.dart'; |
|
||||
|
|
||||
class FTFTranslationController extends GetxController |
|
||||
with GetSingleTickerProviderStateMixin { |
|
||||
final messages = <FTFTranslationMessage>[].obs; |
|
||||
final isChineseInput = true.obs; |
|
||||
final leftLanguage = '中文'.obs; |
|
||||
final rightLanguage = 'English'.obs; |
|
||||
final isLeftInput = true.obs; |
|
||||
|
|
||||
final supportedLanguages = const [ |
|
||||
'中文', |
|
||||
'English', |
|
||||
'日本語', |
|
||||
'한국어', |
|
||||
'Español', |
|
||||
]; |
|
||||
|
|
||||
late final AsrService _asrService; |
|
||||
|
|
||||
StreamSubscription? _recognitionSubscription; |
|
||||
|
|
||||
RxString intermediateContent = ''.obs; |
|
||||
RxString finalContent = ''.obs; |
|
||||
BytesBuilder _originaBytes = BytesBuilder(); //原始音频 |
|
||||
|
|
||||
final VolcanoTranslationService _translationService = |
|
||||
Get.find<VolcanoTranslationService>(); |
|
||||
final LanguageManager _languageManager = Get.find<LanguageManager>(); |
|
||||
// 当前使用的TTS服务 |
|
||||
late TtsService _ttsService; |
|
||||
final isTranslating = false.obs; |
|
||||
final sourceLanguageCode = 'zh-CN'.obs; |
|
||||
final targetLanguageCode = 'en-US'.obs; |
|
||||
final isTtsEnabled = true.obs; |
|
||||
var _currsessionid = ''; |
|
||||
@override |
|
||||
void onInit() { |
|
||||
super.onInit(); |
|
||||
Get.put(XunfeiAsrService()); |
|
||||
final TtsService _ttsService = Get.find<TtsService>(); |
|
||||
_asrService = Get.find<XunfeiAsrService>(); |
|
||||
_InfoRecorder(); |
|
||||
} |
|
||||
|
|
||||
@override |
|
||||
void onClose() { |
|
||||
_asrService.dispose(); |
|
||||
_recognitionSubscription?.cancel(); |
|
||||
super.onClose(); |
|
||||
} |
|
||||
|
|
||||
//开始录音 |
|
||||
Future<void> _InfoRecorder() async { |
|
||||
final recognitionStream = await _asrService.recognizeCallback(); |
|
||||
_recognitionSubscription = recognitionStream.listen( |
|
||||
_handleRecognitionEvent, |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
//开始录音 |
|
||||
Future<void> _startRecorder() async { |
|
||||
await _asrService.startContinuousRecognition(false); |
|
||||
} |
|
||||
|
|
||||
//结束录音 |
|
||||
Future<void> _stopRecorder() async { |
|
||||
await _asrService.stopContinuousRecognition(); |
|
||||
} |
|
||||
|
|
||||
// 处理语音识别事件 |
|
||||
void _handleRecognitionEvent(RecognitionEvent event) { |
|
||||
switch (event.type) { |
|
||||
case RecognitionEventType.intermediateResult: |
|
||||
intermediateContent.value = event.text; |
|
||||
handleFinalResult(event.text); |
|
||||
break; |
|
||||
case RecognitionEventType.finalResult: |
|
||||
finalContent.value += _spliceText(event); |
|
||||
intermediateContent.value = ''; |
|
||||
_originaBytes.add(event.audio!); |
|
||||
handleIntermediateResult(event.text); |
|
||||
break; |
|
||||
default: |
|
||||
// 不做任何处理 |
|
||||
break; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 翻译文本 |
|
||||
Future<String?> translateText(String sourceText, |
|
||||
{bool isFinal = false}) async { |
|
||||
if (sourceText.isEmpty) return null; |
|
||||
|
|
||||
isTranslating.value = true; |
|
||||
String? translationResult; |
|
||||
|
|
||||
try { |
|
||||
// 根据检测到的语言确定源语言和目标语言 |
|
||||
final bool shouldSwap = |
|
||||
intermediateContent.value == sourceLanguageCode.value; |
|
||||
final detectedSourceLanguageCode = |
|
||||
shouldSwap ? sourceLanguageCode.value : targetLanguageCode.value; |
|
||||
final detectedTargetLanguageCode = |
|
||||
shouldSwap ? targetLanguageCode.value : sourceLanguageCode.value; |
|
||||
|
|
||||
// 调用翻译服务 |
|
||||
translationResult = await _translationService.translateText( |
|
||||
text: sourceText, |
|
||||
sourceLanguageCode: detectedSourceLanguageCode, |
|
||||
targetLanguageCode: detectedTargetLanguageCode, |
|
||||
); |
|
||||
|
|
||||
if (translationResult != null) { |
|
||||
// 查找匹配的项目 - 优先使用timestamp查找 |
|
||||
int index = -1; |
|
||||
|
|
||||
// 播放TTS - 只在最终结果时播放 |
|
||||
if (isTtsEnabled.value && isFinal) { |
|
||||
// Logger.info('播放翻译文本: ${translationResult}'); |
|
||||
await playTranslatedText(translationResult); |
|
||||
} |
|
||||
} |
|
||||
} catch (e) { |
|
||||
Logger.error('翻译失败: ${e.toString()}'); |
|
||||
} finally { |
|
||||
isTranslating.value = false; |
|
||||
} |
|
||||
|
|
||||
return translationResult; |
|
||||
} |
|
||||
|
|
||||
// 处理中间识别结果 |
|
||||
void handleIntermediateResult(String text) { |
|
||||
if (text.isEmpty) return; |
|
||||
|
|
||||
translateText(text, isFinal: false); |
|
||||
} |
|
||||
|
|
||||
// 处理最终识别结果 |
|
||||
Future<void> handleFinalResult(String text) async { |
|
||||
if (text.isEmpty) return; |
|
||||
|
|
||||
// 使用项目的时间戳进行翻译 |
|
||||
translateText(text, isFinal: true); |
|
||||
} |
|
||||
|
|
||||
// 播放翻译文本 |
|
||||
Future<void> playTranslatedText(String text) async { |
|
||||
if (text.isEmpty) return; |
|
||||
|
|
||||
try { |
|
||||
final bool shouldSwap = |
|
||||
intermediateContent.value == targetLanguageCode.value; |
|
||||
final detectedTargetLanguageCode = |
|
||||
shouldSwap ? sourceLanguageCode.value : targetLanguageCode.value; |
|
||||
final voiceName = _languageManager |
|
||||
.getTtsVoiceNameByAsrCode(detectedTargetLanguageCode) ?? |
|
||||
'en-US-AriaNeural'; |
|
||||
|
|
||||
await _ttsService.setVoice(voiceName); |
|
||||
await _ttsService.speakOnce(_currsessionid, text); |
|
||||
} catch (e) { |
|
||||
Logger.error('播放翻译失败: ${e.toString()}'); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
String _spliceText(RecognitionEvent event) { |
|
||||
String eventText = event.text; |
|
||||
String text = ''; |
|
||||
if (finalContent.value.isNotEmpty) { |
|
||||
if (event.role != '') { |
|
||||
final punctuations = { |
|
||||
',', |
|
||||
'。', |
|
||||
'!', |
|
||||
'?', |
|
||||
';', |
|
||||
':', |
|
||||
',', |
|
||||
'.', |
|
||||
':', |
|
||||
';', |
|
||||
'!', |
|
||||
'?' |
|
||||
}; |
|
||||
final firstChar = eventText[0]; |
|
||||
if (punctuations.contains(firstChar)) { |
|
||||
text += '$firstChar\n'; |
|
||||
eventText = eventText.substring(1); |
|
||||
} else { |
|
||||
text += '\n'; |
|
||||
} |
|
||||
text += '说话人${event.role}:'; |
|
||||
} |
|
||||
} else { |
|
||||
text += '说话人${event.role}:'; |
|
||||
} |
|
||||
text += eventText; |
|
||||
return text; |
|
||||
} |
|
||||
|
|
||||
void addMessage(String text) { |
|
||||
final fromLang = |
|
||||
isLeftInput.value ? leftLanguage.value : rightLanguage.value; |
|
||||
final toLang = isLeftInput.value ? rightLanguage.value : leftLanguage.value; |
|
||||
|
|
||||
messages.add( |
|
||||
FTFTranslationMessage( |
|
||||
original: text, |
|
||||
translated: '[$fromLang → $toLang] $text (翻译模拟)', |
|
||||
isChineseSpeaker: isLeftInput.value, |
|
||||
), |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
void swapLanguages() { |
|
||||
final temp = leftLanguage.value; |
|
||||
leftLanguage.value = rightLanguage.value; |
|
||||
rightLanguage.value = temp; |
|
||||
isLeftInput.value = !isLeftInput.value; |
|
||||
} |
|
||||
|
|
||||
void toggleInputSide(bool isLeft) => isLeftInput.value = isLeft; |
|
||||
} |
|
||||
|
|
||||
class FTFTranslationMessage { |
|
||||
final String original; |
|
||||
final String translated; |
|
||||
final bool isChineseSpeaker; |
|
||||
|
|
||||
FTFTranslationMessage({ |
|
||||
required this.original, |
|
||||
required this.translated, |
|
||||
required this.isChineseSpeaker, |
|
||||
}); |
|
||||
} |
|
||||
@ -1,212 +0,0 @@ |
|||||
import 'package:flutter/material.dart'; |
|
||||
import 'package:get/get.dart'; |
|
||||
import '../controllers/FTFTranslation_controller.dart'; |
|
||||
|
|
||||
class FTFTranslationView extends GetView<FTFTranslationController> { |
|
||||
const FTFTranslationView({Key? key}) : super(key: key); |
|
||||
|
|
||||
@override |
|
||||
Widget build(BuildContext context) { |
|
||||
final TextEditingController inputController = TextEditingController(); |
|
||||
|
|
||||
return Scaffold( |
|
||||
backgroundColor: const Color(0xFF0E0F12), |
|
||||
appBar: AppBar( |
|
||||
backgroundColor: Colors.black, |
|
||||
title: const Text('面对面翻译'), |
|
||||
centerTitle: true, |
|
||||
), |
|
||||
body: Column( |
|
||||
children: [ |
|
||||
Expanded( |
|
||||
child: Obx(() => ListView.builder( |
|
||||
padding: const EdgeInsets.all(16), |
|
||||
itemCount: controller.messages.length, |
|
||||
itemBuilder: (context, index) { |
|
||||
final msg = controller.messages[index]; |
|
||||
final isChinese = msg.isChineseSpeaker; |
|
||||
return Align( |
|
||||
alignment: isChinese |
|
||||
? Alignment.centerLeft |
|
||||
: Alignment.centerRight, |
|
||||
child: Container( |
|
||||
margin: const EdgeInsets.symmetric(vertical: 6), |
|
||||
padding: const EdgeInsets.all(12), |
|
||||
decoration: BoxDecoration( |
|
||||
color: isChinese ? Colors.blue : Colors.grey[800], |
|
||||
borderRadius: BorderRadius.circular(12), |
|
||||
), |
|
||||
constraints: const BoxConstraints(maxWidth: 300), |
|
||||
child: Column( |
|
||||
crossAxisAlignment: CrossAxisAlignment.start, |
|
||||
children: [ |
|
||||
Text( |
|
||||
msg.original, |
|
||||
style: const TextStyle( |
|
||||
color: Colors.white, |
|
||||
fontWeight: FontWeight.bold, |
|
||||
), |
|
||||
), |
|
||||
const SizedBox(height: 4), |
|
||||
Text( |
|
||||
msg.translated, |
|
||||
style: TextStyle( |
|
||||
color: Colors.white.withOpacity(0.9), |
|
||||
fontSize: 14, |
|
||||
), |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
), |
|
||||
); |
|
||||
}, |
|
||||
)), |
|
||||
), |
|
||||
_buildBottomInput(inputController), |
|
||||
], |
|
||||
), |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
Widget _buildBottomInput(TextEditingController inputController) { |
|
||||
return Obx(() { |
|
||||
final isLeftInput = controller.isLeftInput.value; |
|
||||
final inputLang = isLeftInput |
|
||||
? controller.leftLanguage.value |
|
||||
: controller.rightLanguage.value; |
|
||||
|
|
||||
return Container( |
|
||||
color: Colors.black, |
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), |
|
||||
child: Column( |
|
||||
children: [ |
|
||||
Row( |
|
||||
children: [ |
|
||||
Expanded( |
|
||||
child: TextField( |
|
||||
controller: inputController, |
|
||||
style: const TextStyle(color: Colors.white), |
|
||||
decoration: InputDecoration( |
|
||||
filled: true, |
|
||||
fillColor: Colors.grey[900], |
|
||||
hintText: '请输入 $inputLang 内容', |
|
||||
hintStyle: const TextStyle(color: Colors.grey), |
|
||||
border: OutlineInputBorder( |
|
||||
borderRadius: BorderRadius.circular(8), |
|
||||
borderSide: BorderSide.none, |
|
||||
), |
|
||||
contentPadding: |
|
||||
const EdgeInsets.symmetric(horizontal: 12), |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
IconButton( |
|
||||
onPressed: () { |
|
||||
final text = inputController.text.trim(); |
|
||||
if (text.isNotEmpty) { |
|
||||
controller.addMessage(text); |
|
||||
inputController.clear(); |
|
||||
} |
|
||||
}, |
|
||||
icon: const Icon(Icons.send, color: Colors.white), |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
const SizedBox(height: 8), |
|
||||
Row( |
|
||||
children: [ |
|
||||
Expanded( |
|
||||
child: _buildLanguageDropdown( |
|
||||
selected: controller.leftLanguage.value, |
|
||||
items: controller.supportedLanguages, |
|
||||
onChanged: (value) { |
|
||||
if (value != null) controller.leftLanguage.value = value; |
|
||||
}, |
|
||||
icon: Icons.headphones, |
|
||||
isSelected: isLeftInput, |
|
||||
label: '左方语言', |
|
||||
onTapInputSide: () => controller.toggleInputSide(true), |
|
||||
), |
|
||||
), |
|
||||
const SizedBox(width: 12), |
|
||||
GestureDetector( |
|
||||
onTap: controller.swapLanguages, |
|
||||
child: Container( |
|
||||
padding: const EdgeInsets.all(8), |
|
||||
decoration: BoxDecoration( |
|
||||
color: Colors.white10, |
|
||||
shape: BoxShape.circle, |
|
||||
), |
|
||||
child: |
|
||||
const Icon(Icons.compare_arrows, color: Colors.white), |
|
||||
), |
|
||||
), |
|
||||
const SizedBox(width: 12), |
|
||||
Expanded( |
|
||||
child: _buildLanguageDropdown( |
|
||||
selected: controller.rightLanguage.value, |
|
||||
items: controller.supportedLanguages, |
|
||||
onChanged: (value) { |
|
||||
if (value != null) controller.rightLanguage.value = value; |
|
||||
}, |
|
||||
icon: Icons.smartphone, |
|
||||
isSelected: !isLeftInput, |
|
||||
label: '右方语言', |
|
||||
onTapInputSide: () => controller.toggleInputSide(false), |
|
||||
), |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
); |
|
||||
}); |
|
||||
} |
|
||||
|
|
||||
Widget _buildLanguageDropdown({ |
|
||||
required String selected, |
|
||||
required List<String> items, |
|
||||
required void Function(String?) onChanged, |
|
||||
required IconData icon, |
|
||||
required bool isSelected, |
|
||||
required String label, |
|
||||
required VoidCallback onTapInputSide, |
|
||||
}) { |
|
||||
return GestureDetector( |
|
||||
onTap: onTapInputSide, |
|
||||
child: Container( |
|
||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12), |
|
||||
decoration: BoxDecoration( |
|
||||
border: Border.all( |
|
||||
color: isSelected ? Colors.white : Colors.grey, |
|
||||
), |
|
||||
borderRadius: BorderRadius.circular(24), |
|
||||
color: isSelected ? Colors.white10 : Colors.transparent, |
|
||||
), |
|
||||
child: Row( |
|
||||
children: [ |
|
||||
Icon(icon, color: Colors.white), |
|
||||
const SizedBox(width: 8), |
|
||||
Expanded( |
|
||||
child: DropdownButton<String>( |
|
||||
dropdownColor: Colors.grey[900], |
|
||||
value: selected, |
|
||||
isExpanded: true, |
|
||||
underline: const SizedBox(), |
|
||||
iconEnabledColor: Colors.white, |
|
||||
style: const TextStyle(color: Colors.white), |
|
||||
items: items.map((lang) { |
|
||||
return DropdownMenuItem( |
|
||||
value: lang, |
|
||||
child: Text(lang), |
|
||||
); |
|
||||
}).toList(), |
|
||||
onChanged: onChanged, |
|
||||
), |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
), |
|
||||
); |
|
||||
} |
|
||||
} |
|
||||
Loading…
Reference in new issue