22 changed files with 1495 additions and 1466 deletions
@ -1,526 +0,0 @@ |
|||||
package com.example.deep_voice |
|
||||
|
|
||||
import android.content.Context |
|
||||
import android.util.Log |
|
||||
import com.bytedance.speech.speechengine.SpeechEngine |
|
||||
import com.bytedance.speech.speechengine.SpeechEngineDefines |
|
||||
import com.bytedance.speech.speechengine.SpeechEngineGenerator |
|
||||
import org.json.JSONException |
|
||||
import org.json.JSONObject |
|
||||
import java.util.concurrent.CountDownLatch |
|
||||
import java.util.concurrent.TimeUnit |
|
||||
|
|
||||
/** |
|
||||
* 火山语音识别Helper类 |
|
||||
* |
|
||||
* 该类封装了火山语音SDK的语音识别功能,提供简单的接口供Flutter调用 |
|
||||
*/ |
|
||||
class VolcanoAsrHelper(context: Context) : SpeechEngine.SpeechListener { |
|
||||
private val TAG = "VolcanoAsrHelper" |
|
||||
private var mSpeechEngine: SpeechEngine? = null |
|
||||
private var mSpeechEngineHandler: Long = -1 |
|
||||
private var isInitialized = false |
|
||||
private var applicationContext: Context? = context.applicationContext |
|
||||
|
|
||||
// 存储应用ID和密钥,以便在错误处理中使用 |
|
||||
private var appId: String = "" |
|
||||
private var appKey: String = "" |
|
||||
private var cluster: String = "" // 移除默认值 |
|
||||
|
|
||||
// 同步识别相关变量 |
|
||||
private var mRecognitionLatch: CountDownLatch? = null |
|
||||
private var mRecognitionResult: String? = null |
|
||||
private var mRecognitionError: String? = null |
|
||||
|
|
||||
// 当前回调 |
|
||||
private var mCurrentCallback: RecognizeCallback? = null |
|
||||
private var mCurrentContinuousCallback: ContinuousRecognizeCallback? = null |
|
||||
|
|
||||
// 是否正在进行连续识别 |
|
||||
private var mIsContinuousRecognitionActive = false |
|
||||
|
|
||||
/** |
|
||||
* 初始化语音识别引擎 |
|
||||
* @param appId 应用ID |
|
||||
* @param token 应用密钥/令牌 |
|
||||
* @param cluster 集群区域 |
|
||||
* @param resourceId 资源ID,用于大模型流式识别API鉴权 |
|
||||
* @return 初始化是否成功 |
|
||||
*/ |
|
||||
fun initialize(appId: String, token: String, cluster: String, resourceId: String? = null): Boolean { |
|
||||
try { |
|
||||
// 设置基本参数 |
|
||||
this.appId = appId |
|
||||
this.appKey = token |
|
||||
this.cluster = cluster |
|
||||
|
|
||||
Log.d(TAG, "初始化参数: APP_ID=${appId}, APP_KEY长度=${token.length}, 集群区域=${cluster}") |
|
||||
Log.d(TAG, "资源ID: ${resourceId ?: appId}") |
|
||||
|
|
||||
// 确保应用上下文已设置 |
|
||||
if (applicationContext == null) { |
|
||||
Log.e(TAG, "应用上下文未设置,请先调用setContext方法") |
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
// 准备环境 |
|
||||
SpeechEngineGenerator.PrepareEnvironment(applicationContext, null) |
|
||||
|
|
||||
// 获取语音引擎实例 |
|
||||
mSpeechEngine = SpeechEngineGenerator.getInstance() |
|
||||
mSpeechEngineHandler = mSpeechEngine?.createEngine() ?: -1 |
|
||||
|
|
||||
if (mSpeechEngineHandler == -1L) { |
|
||||
Log.e(TAG, "创建语音引擎失败") |
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
// 设置上下文 |
|
||||
mSpeechEngine?.setContext(applicationContext) |
|
||||
|
|
||||
// 设置引擎类型为ASR |
|
||||
mSpeechEngine?.setOptionString( |
|
||||
mSpeechEngineHandler, |
|
||||
SpeechEngineDefines.PARAMS_KEY_ENGINE_NAME_STRING, |
|
||||
SpeechEngineDefines.ASR_ENGINE |
|
||||
) |
|
||||
|
|
||||
// 设置日志级别 |
|
||||
mSpeechEngine?.setOptionString( |
|
||||
mSpeechEngineHandler, |
|
||||
SpeechEngineDefines.PARAMS_KEY_LOG_LEVEL_STRING, |
|
||||
SpeechEngineDefines.LOG_LEVEL_DEBUG // 设置为DEBUG级别以获取更多日志信息 |
|
||||
) |
|
||||
|
|
||||
// 设置用户ID (必需) |
|
||||
mSpeechEngine?.setOptionString( |
|
||||
mSpeechEngineHandler, |
|
||||
SpeechEngineDefines.PARAMS_KEY_UID_STRING, |
|
||||
"deep_voice_user" |
|
||||
) |
|
||||
|
|
||||
// 设置设备ID (可选) |
|
||||
mSpeechEngine?.setOptionString( |
|
||||
mSpeechEngineHandler, |
|
||||
SpeechEngineDefines.PARAMS_KEY_DEVICE_ID_STRING, |
|
||||
"deep_voice_device" |
|
||||
) |
|
||||
|
|
||||
// 设置授权信息 - APP_ID |
|
||||
mSpeechEngine?.setOptionString( |
|
||||
mSpeechEngineHandler, |
|
||||
SpeechEngineDefines.PARAMS_KEY_APP_ID_STRING, |
|
||||
appId |
|
||||
) |
|
||||
|
|
||||
|
|
||||
// 使用大模型流式识别SDK (API v3) |
|
||||
Log.d(TAG, "使用大模型流式识别SDK配置 (API v3)") |
|
||||
|
|
||||
// 设置API路径为大模型流式识别API |
|
||||
mSpeechEngine?.setOptionString( |
|
||||
mSpeechEngineHandler, |
|
||||
SpeechEngineDefines.PARAMS_KEY_ASR_URI_STRING, |
|
||||
"/api/v3/sauc/bigmodel" |
|
||||
) |
|
||||
|
|
||||
// 设置授权信息 - APP_TOKEN (不需要Bearer前缀) |
|
||||
mSpeechEngine?.setOptionString( |
|
||||
mSpeechEngineHandler, |
|
||||
SpeechEngineDefines.PARAMS_KEY_APP_TOKEN_STRING, |
|
||||
token |
|
||||
) |
|
||||
|
|
||||
// 设置资源ID,用于大模型流式识别API鉴权 |
|
||||
mSpeechEngine?.setOptionString( |
|
||||
mSpeechEngineHandler, |
|
||||
SpeechEngineDefines.PARAMS_KEY_RESOURCE_ID_STRING, |
|
||||
resourceId |
|
||||
) |
|
||||
|
|
||||
// 设置协议类型为SEED |
|
||||
mSpeechEngine?.setOptionInt( |
|
||||
mSpeechEngineHandler, |
|
||||
SpeechEngineDefines.PARAMS_KEY_PROTOCOL_TYPE_INT, |
|
||||
SpeechEngineDefines.PROTOCOL_TYPE_SEED |
|
||||
) |
|
||||
|
|
||||
// 设置ASR请求参数 |
|
||||
mSpeechEngine?.setOptionString( |
|
||||
mSpeechEngineHandler, |
|
||||
SpeechEngineDefines.PARAMS_KEY_ASR_REQ_PARAMS_STRING, |
|
||||
"{\"force_to_speech_time\":0, \"end_window_size\":800}" |
|
||||
) |
|
||||
|
|
||||
|
|
||||
// 设置网络配置 |
|
||||
mSpeechEngine?.setOptionString( |
|
||||
mSpeechEngineHandler, |
|
||||
SpeechEngineDefines.PARAMS_KEY_ASR_ADDRESS_STRING, |
|
||||
"wss://openspeech.bytedance.com" |
|
||||
) |
|
||||
|
|
||||
// 设置集群区域 - 这对于WebSocket连接非常重要 |
|
||||
mSpeechEngine?.setOptionString( |
|
||||
mSpeechEngineHandler, |
|
||||
SpeechEngineDefines.PARAMS_KEY_ASR_CLUSTER_STRING, |
|
||||
cluster |
|
||||
) |
|
||||
|
|
||||
// 设置音频来源为内置录音机 |
|
||||
mSpeechEngine?.setOptionString( |
|
||||
mSpeechEngineHandler, |
|
||||
SpeechEngineDefines.PARAMS_KEY_RECORDER_TYPE_STRING, |
|
||||
SpeechEngineDefines.RECORDER_TYPE_RECORDER |
|
||||
) |
|
||||
|
|
||||
// 启用音量获取 |
|
||||
mSpeechEngine?.setOptionBoolean( |
|
||||
mSpeechEngineHandler, |
|
||||
SpeechEngineDefines.PARAMS_KEY_ENABLE_GET_VOLUME_BOOL, |
|
||||
true |
|
||||
) |
|
||||
|
|
||||
// 设置最大录音时长为60秒 |
|
||||
mSpeechEngine?.setOptionInt( |
|
||||
mSpeechEngineHandler, |
|
||||
SpeechEngineDefines.PARAMS_KEY_VAD_MAX_SPEECH_DURATION_INT, |
|
||||
60000 |
|
||||
) |
|
||||
|
|
||||
// 控制识别效果 |
|
||||
mSpeechEngine?.setOptionBoolean( |
|
||||
mSpeechEngineHandler, |
|
||||
SpeechEngineDefines.PARAMS_KEY_ASR_ENABLE_DDC_BOOL, |
|
||||
true |
|
||||
) |
|
||||
mSpeechEngine?.setOptionBoolean( |
|
||||
mSpeechEngineHandler, |
|
||||
SpeechEngineDefines.PARAMS_KEY_ASR_SHOW_NLU_PUNC_BOOL, |
|
||||
true |
|
||||
) |
|
||||
|
|
||||
// 设置识别结果形式为全量返回(适合一句话识别) |
|
||||
mSpeechEngine?.setOptionString( |
|
||||
mSpeechEngineHandler, |
|
||||
SpeechEngineDefines.PARAMS_KEY_ASR_RESULT_TYPE_STRING, |
|
||||
SpeechEngineDefines.ASR_RESULT_TYPE_SINGLE |
|
||||
) |
|
||||
|
|
||||
// 记录所有配置参数,便于调试 |
|
||||
Log.d(TAG, "配置参数汇总:") |
|
||||
Log.d(TAG, "APP_ID: $appId") |
|
||||
Log.d(TAG, "APP_TOKEN: ${token.take(5)}***") |
|
||||
Log.d(TAG, "CLUSTER: $cluster") |
|
||||
Log.d(TAG, "RESOURCE_ID: ${resourceId ?: appId}") |
|
||||
Log.d(TAG, "API_PATH: ${if (resourceId != null) "/api/v3/sauc/bigmodel" else "/api/v2/asr"}") |
|
||||
|
|
||||
// 初始化引擎 |
|
||||
val ret = mSpeechEngine?.initEngine(mSpeechEngineHandler) ?: -1 |
|
||||
if (ret != SpeechEngineDefines.ERR_NO_ERROR) { |
|
||||
Log.e(TAG, "初始化引擎失败: $ret") |
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
// 设置监听器 |
|
||||
mSpeechEngine?.setListener(this) |
|
||||
|
|
||||
isInitialized = true |
|
||||
Log.d(TAG, "火山语音识别引擎初始化成功") |
|
||||
return true |
|
||||
} catch (e: Exception) { |
|
||||
Log.e(TAG, "火山语音识别引擎初始化失败: ${e.message}") |
|
||||
e.printStackTrace() |
|
||||
return false |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 设置应用上下文 |
|
||||
*/ |
|
||||
fun setContext(context: Context) { |
|
||||
applicationContext = context.applicationContext |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 一次性识别 |
|
||||
* |
|
||||
* @param callback 回调接口,用于返回结果或错误 |
|
||||
*/ |
|
||||
fun recognizeOnce(callback: RecognizeCallback) { |
|
||||
if (!isInitialized) { |
|
||||
callback.onError("语音识别引擎尚未初始化") |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
try { |
|
||||
Log.d(TAG, "开始一次性识别") |
|
||||
|
|
||||
// 保存回调以便在onMessage中使用 |
|
||||
mCurrentCallback = callback |
|
||||
|
|
||||
// 设置识别结果形式为全量返回(适合一句话识别) |
|
||||
mSpeechEngine?.setOptionString( |
|
||||
mSpeechEngineHandler, |
|
||||
SpeechEngineDefines.PARAMS_KEY_ASR_RESULT_TYPE_STRING, |
|
||||
SpeechEngineDefines.ASR_RESULT_TYPE_FULL |
|
||||
) |
|
||||
|
|
||||
// 先停止引擎,避免SDK内部异步线程带来的问题 |
|
||||
mSpeechEngine?.sendDirective(mSpeechEngineHandler, SpeechEngineDefines.DIRECTIVE_SYNC_STOP_ENGINE, "") |
|
||||
|
|
||||
// 启动引擎 |
|
||||
val ret = mSpeechEngine?.sendDirective(mSpeechEngineHandler, SpeechEngineDefines.DIRECTIVE_START_ENGINE, "") |
|
||||
if (ret != SpeechEngineDefines.ERR_NO_ERROR) { |
|
||||
callback.onError("启动引擎失败: $ret") |
|
||||
} |
|
||||
} catch (e: Exception) { |
|
||||
Log.e(TAG, "语音识别异常: ${e.message}") |
|
||||
callback.onError("语音识别异常: ${e.message}") |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 开始连续识别 |
|
||||
* |
|
||||
* @param callback 回调接口,用于返回结果或错误 |
|
||||
*/ |
|
||||
fun startContinuousRecognition(callback: ContinuousRecognizeCallback): Boolean { |
|
||||
if (!isInitialized) { |
|
||||
callback.onError("语音识别引擎尚未初始化") |
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
if (mIsContinuousRecognitionActive) { |
|
||||
callback.onError("连续识别已经在进行中") |
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
try { |
|
||||
Log.d(TAG, "开始连续识别") |
|
||||
|
|
||||
// 保存回调以便在onMessage中使用 |
|
||||
mCurrentContinuousCallback = callback |
|
||||
|
|
||||
// 设置识别结果形式为增量返回(适合连续识别) |
|
||||
mSpeechEngine?.setOptionString( |
|
||||
mSpeechEngineHandler, |
|
||||
SpeechEngineDefines.PARAMS_KEY_ASR_RESULT_TYPE_STRING, |
|
||||
SpeechEngineDefines.ASR_RESULT_TYPE_SINGLE |
|
||||
) |
|
||||
|
|
||||
// 先停止引擎,避免SDK内部异步线程带来的问题 |
|
||||
mSpeechEngine?.sendDirective(mSpeechEngineHandler, SpeechEngineDefines.DIRECTIVE_SYNC_STOP_ENGINE, "") |
|
||||
|
|
||||
// 启动引擎 |
|
||||
val ret = mSpeechEngine?.sendDirective(mSpeechEngineHandler, SpeechEngineDefines.DIRECTIVE_START_ENGINE, "") |
|
||||
if (ret != SpeechEngineDefines.ERR_NO_ERROR) { |
|
||||
callback.onError("启动引擎失败: $ret") |
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
mIsContinuousRecognitionActive = true |
|
||||
return true |
|
||||
} catch (e: Exception) { |
|
||||
Log.e(TAG, "开始连续识别异常: ${e.message}") |
|
||||
callback.onError("开始连续识别异常: ${e.message}") |
|
||||
return false |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 停止连续识别 |
|
||||
* |
|
||||
* @param callback 回调接口,用于返回结果或错误 |
|
||||
*/ |
|
||||
fun stopContinuousRecognition(callback: ContinuousRecognizeCallback): Boolean { |
|
||||
if (!isInitialized) { |
|
||||
callback.onError("语音识别引擎尚未初始化") |
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
if (!mIsContinuousRecognitionActive) { |
|
||||
callback.onError("连续识别未在进行中") |
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
try { |
|
||||
Log.d(TAG, "停止连续识别") |
|
||||
|
|
||||
// 音频输入完成 |
|
||||
mSpeechEngine?.sendDirective(mSpeechEngineHandler, SpeechEngineDefines.DIRECTIVE_FINISH_TALKING, "") |
|
||||
|
|
||||
// 停止引擎 |
|
||||
val ret = mSpeechEngine?.sendDirective(mSpeechEngineHandler, SpeechEngineDefines.DIRECTIVE_STOP_ENGINE, "") |
|
||||
if (ret != SpeechEngineDefines.ERR_NO_ERROR) { |
|
||||
callback.onError("停止引擎失败: $ret") |
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
mIsContinuousRecognitionActive = false |
|
||||
return true |
|
||||
} catch (e: Exception) { |
|
||||
Log.e(TAG, "停止连续识别异常: ${e.message}") |
|
||||
callback.onError("停止连续识别异常: ${e.message}") |
|
||||
return false |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 检查连续识别是否活跃 |
|
||||
*/ |
|
||||
fun isContinuousRecognitionActive(): Boolean { |
|
||||
return mIsContinuousRecognitionActive |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 释放资源 |
|
||||
*/ |
|
||||
fun dispose() { |
|
||||
try { |
|
||||
if (mSpeechEngineHandler != -1L) { |
|
||||
mSpeechEngine?.sendDirective(mSpeechEngineHandler, SpeechEngineDefines.DIRECTIVE_STOP_ENGINE, "") |
|
||||
mSpeechEngine?.destroyEngine(mSpeechEngineHandler) |
|
||||
mSpeechEngineHandler = -1 |
|
||||
} |
|
||||
mSpeechEngine = null |
|
||||
isInitialized = false |
|
||||
mIsContinuousRecognitionActive = false |
|
||||
Log.d(TAG, "火山语音识别引擎已释放") |
|
||||
} catch (e: Exception) { |
|
||||
Log.e(TAG, "释放火山语音识别引擎失败: ${e.message}") |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 实现SpeechListener接口 |
|
||||
override fun onSpeechMessage(type: Int, data: ByteArray, len: Int) { |
|
||||
val stdData = String(data) |
|
||||
|
|
||||
when (type) { |
|
||||
SpeechEngineDefines.MESSAGE_TYPE_ENGINE_START -> { |
|
||||
Log.d(TAG, "引擎启动成功: $stdData") |
|
||||
mCurrentContinuousCallback?.onSessionStarted() |
|
||||
} |
|
||||
SpeechEngineDefines.MESSAGE_TYPE_ENGINE_STOP -> { |
|
||||
Log.d(TAG, "引擎已停止: $stdData") |
|
||||
mIsContinuousRecognitionActive = false |
|
||||
mCurrentContinuousCallback?.onSessionStopped() |
|
||||
} |
|
||||
SpeechEngineDefines.MESSAGE_TYPE_ENGINE_ERROR -> { |
|
||||
Log.e(TAG, "引擎错误: $stdData") |
|
||||
try { |
|
||||
val errorJson = JSONObject(stdData) |
|
||||
val errorCode = errorJson.optInt("err_code", -1) |
|
||||
val errorMsg = errorJson.optString("err_msg", "未知错误") |
|
||||
|
|
||||
// 检查是否是资源授权错误 |
|
||||
if (errorCode == 1001 && (errorMsg.contains("requested resource not granted") || |
|
||||
errorMsg.contains("requested grant not found"))) { |
|
||||
Log.e(TAG, "资源授权错误: 应用可能未开通语音识别服务或密钥无效") |
|
||||
|
|
||||
// 记录更多调试信息,但隐藏敏感信息 |
|
||||
Log.d(TAG, "APP_ID长度: ${appId.length}") |
|
||||
Log.d(TAG, "APP_KEY长度: ${appKey.length}") |
|
||||
|
|
||||
val errorMessage = "资源授权错误: 请确保应用已开通语音识别服务并且密钥有效 (错误码: $errorCode)" |
|
||||
|
|
||||
if (mIsContinuousRecognitionActive) { |
|
||||
mCurrentContinuousCallback?.onError(errorMessage) |
|
||||
} else { |
|
||||
mCurrentCallback?.onError(errorMessage) |
|
||||
} |
|
||||
} else { |
|
||||
// 其他错误 |
|
||||
if (mIsContinuousRecognitionActive) { |
|
||||
mCurrentContinuousCallback?.onError("引擎错误: $errorMsg (错误码: $errorCode)") |
|
||||
} else { |
|
||||
mCurrentCallback?.onError("引擎错误: $errorMsg (错误码: $errorCode)") |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
mRecognitionError = "引擎错误: $stdData" |
|
||||
mRecognitionLatch?.countDown() |
|
||||
} catch (e: JSONException) { |
|
||||
Log.e(TAG, "解析错误信息失败: ${e.message}") |
|
||||
if (mIsContinuousRecognitionActive) { |
|
||||
mCurrentContinuousCallback?.onError("引擎错误: $stdData") |
|
||||
} else { |
|
||||
mCurrentCallback?.onError("引擎错误: $stdData") |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
SpeechEngineDefines.MESSAGE_TYPE_PARTIAL_RESULT -> { |
|
||||
Log.d(TAG, "中间识别结果: $stdData") |
|
||||
processRecognitionResult(stdData, false) |
|
||||
} |
|
||||
SpeechEngineDefines.MESSAGE_TYPE_FINAL_RESULT -> { |
|
||||
Log.d(TAG, "最终识别结果: $stdData") |
|
||||
processRecognitionResult(stdData, true) |
|
||||
} |
|
||||
SpeechEngineDefines.MESSAGE_TYPE_VOLUME_LEVEL -> { |
|
||||
// 音量级别,可用于显示波形 |
|
||||
// Log.d(TAG, "音量级别: $stdData") |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 处理识别结果 |
|
||||
*/ |
|
||||
private fun processRecognitionResult(resultData: String, isFinal: Boolean) { |
|
||||
try { |
|
||||
val resultJson = JSONObject(resultData) |
|
||||
if (!resultJson.has("result")) { |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
val resultArray = resultJson.getJSONArray("result") |
|
||||
if (resultArray.length() == 0) { |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
val resultObj = resultArray.getJSONObject(0) |
|
||||
val text = resultObj.optString("text", "") |
|
||||
|
|
||||
if (text.isEmpty()) { |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
if (mIsContinuousRecognitionActive) { |
|
||||
if (isFinal) { |
|
||||
mCurrentContinuousCallback?.onResult(text) |
|
||||
} else { |
|
||||
mCurrentContinuousCallback?.onRecognizing(text) |
|
||||
} |
|
||||
} else { |
|
||||
if (isFinal) { |
|
||||
mCurrentCallback?.onResult(text) |
|
||||
mRecognitionResult = text |
|
||||
mRecognitionLatch?.countDown() |
|
||||
} |
|
||||
} |
|
||||
} catch (e: JSONException) { |
|
||||
Log.e(TAG, "解析识别结果失败: ${e.message}") |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 一次性识别回调接口 |
|
||||
*/ |
|
||||
interface RecognizeCallback { |
|
||||
fun onResult(text: String) |
|
||||
fun onError(error: String) |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 连续识别回调接口 |
|
||||
*/ |
|
||||
interface ContinuousRecognizeCallback { |
|
||||
fun onResult(text: String) |
|
||||
fun onRecognizing(recognizing: String) |
|
||||
fun onSessionStarted() |
|
||||
fun onSessionStopped() |
|
||||
fun onCanceled(reason: String, errorDetails: String) |
|
||||
fun onError(error: String) |
|
||||
} |
|
||||
} |
|
||||
@ -1,438 +0,0 @@ |
|||||
package com.example.deep_voice |
|
||||
|
|
||||
import android.content.Context |
|
||||
import android.util.Log |
|
||||
import com.bytedance.speech.speechengine.SpeechEngine |
|
||||
import com.bytedance.speech.speechengine.SpeechEngineDefines |
|
||||
import com.bytedance.speech.speechengine.SpeechEngineGenerator |
|
||||
|
|
||||
/** |
|
||||
* 火山语音合成Helper类 |
|
||||
* |
|
||||
* 该类封装了火山语音SDK的TTS功能,提供简单的接口供Flutter调用 |
|
||||
* 实现基于官方文档的标准接入流程 |
|
||||
*/ |
|
||||
class VolcanoTtsHelper(context: Context) : SpeechEngine.SpeechListener { |
|
||||
private val TAG = "VolcanoTtsHelper" |
|
||||
private var mSpeechEngine: SpeechEngine? = null |
|
||||
private var mSpeechEngineHandler: Long = -1 |
|
||||
private var isInitialized = false |
|
||||
private var applicationContext: Context? = context.applicationContext |
|
||||
|
|
||||
// 播放状态回调 |
|
||||
private var mPlaybackCallback: PlaybackCallback? = null |
|
||||
|
|
||||
/** |
|
||||
* 初始化TTS引擎 |
|
||||
* |
|
||||
* @param appId 火山语音服务AppID |
|
||||
* @param token 火山语音服务Token |
|
||||
* @param cluster 火山语音服务集群 |
|
||||
*/ |
|
||||
fun initialize(appId: String, token: String, cluster: String): Boolean { |
|
||||
try { |
|
||||
// 确保应用上下文已设置 |
|
||||
if (applicationContext == null) { |
|
||||
Log.e(TAG, "应用上下文未设置") |
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
Log.d(TAG, "初始化火山语音TTS引擎,APP_ID: ${appId.take(3)}***,Token长度: ${token.length},集群区域: $cluster") |
|
||||
|
|
||||
// 1. 准备环境 |
|
||||
SpeechEngineGenerator.PrepareEnvironment(applicationContext, null) |
|
||||
|
|
||||
// 2. 创建引擎实例 |
|
||||
mSpeechEngine = SpeechEngineGenerator.getInstance() |
|
||||
mSpeechEngineHandler = mSpeechEngine?.createEngine() ?: -1 |
|
||||
|
|
||||
if (mSpeechEngineHandler == -1L) { |
|
||||
Log.e(TAG, "创建语音引擎失败") |
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
// 设置上下文 |
|
||||
mSpeechEngine?.setContext(applicationContext) |
|
||||
|
|
||||
// 3. 参数配置 |
|
||||
// 3.1 引擎类型 |
|
||||
mSpeechEngine?.setOptionString( |
|
||||
mSpeechEngineHandler, |
|
||||
SpeechEngineDefines.PARAMS_KEY_ENGINE_NAME_STRING, |
|
||||
SpeechEngineDefines.TTS_ENGINE |
|
||||
) |
|
||||
|
|
||||
// 3.2 日志级别 |
|
||||
mSpeechEngine?.setOptionString( |
|
||||
mSpeechEngineHandler, |
|
||||
SpeechEngineDefines.PARAMS_KEY_LOG_LEVEL_STRING, |
|
||||
SpeechEngineDefines.LOG_LEVEL_WARN |
|
||||
) |
|
||||
|
|
||||
// 3.3 线上问题定位 |
|
||||
mSpeechEngine?.setOptionString( |
|
||||
mSpeechEngineHandler, |
|
||||
SpeechEngineDefines.PARAMS_KEY_UID_STRING, |
|
||||
"deep_voice_user" |
|
||||
) |
|
||||
|
|
||||
mSpeechEngine?.setOptionString( |
|
||||
mSpeechEngineHandler, |
|
||||
SpeechEngineDefines.PARAMS_KEY_DEVICE_ID_STRING, |
|
||||
"deep_voice_device" |
|
||||
) |
|
||||
|
|
||||
// 3.4 授权 |
|
||||
mSpeechEngine?.setOptionString( |
|
||||
mSpeechEngineHandler, |
|
||||
SpeechEngineDefines.PARAMS_KEY_APP_ID_STRING, |
|
||||
appId |
|
||||
) |
|
||||
mSpeechEngine?.setOptionString( |
|
||||
mSpeechEngineHandler, |
|
||||
SpeechEngineDefines.PARAMS_KEY_APP_TOKEN_STRING, |
|
||||
"Bearer;$token" |
|
||||
) |
|
||||
|
|
||||
// 3.5 合成场景 |
|
||||
mSpeechEngine?.setOptionString( |
|
||||
mSpeechEngineHandler, |
|
||||
SpeechEngineDefines.PARAMS_KEY_TTS_SCENARIO_STRING, |
|
||||
SpeechEngineDefines.TTS_SCENARIO_TYPE_NOVEL |
|
||||
) |
|
||||
|
|
||||
// 3.6 合成策略 |
|
||||
mSpeechEngine?.setOptionInt( |
|
||||
mSpeechEngineHandler, |
|
||||
SpeechEngineDefines.PARAMS_KEY_TTS_WORK_MODE_INT, |
|
||||
SpeechEngineDefines.TTS_WORK_MODE_ONLINE |
|
||||
) |
|
||||
|
|
||||
// 3.7 在线请求资源配置 |
|
||||
mSpeechEngine?.setOptionString( |
|
||||
mSpeechEngineHandler, |
|
||||
SpeechEngineDefines.PARAMS_KEY_TTS_ADDRESS_STRING, |
|
||||
"wss://openspeech.bytedance.com" |
|
||||
) |
|
||||
mSpeechEngine?.setOptionString( |
|
||||
mSpeechEngineHandler, |
|
||||
SpeechEngineDefines.PARAMS_KEY_TTS_URI_STRING, |
|
||||
"/api/v1/tts/ws_binary" |
|
||||
) |
|
||||
mSpeechEngine?.setOptionString( |
|
||||
mSpeechEngineHandler, |
|
||||
SpeechEngineDefines.PARAMS_KEY_TTS_CLUSTER_STRING, |
|
||||
cluster |
|
||||
) |
|
||||
|
|
||||
// 3.8 启用内建播放器 |
|
||||
mSpeechEngine?.setOptionBoolean( |
|
||||
mSpeechEngineHandler, |
|
||||
SpeechEngineDefines.PARAMS_KEY_TTS_ENABLE_PLAYER_BOOL, |
|
||||
true |
|
||||
) |
|
||||
|
|
||||
// 3.9 配置内建播放器的音源 |
|
||||
mSpeechEngine?.setOptionInt( |
|
||||
mSpeechEngineHandler, |
|
||||
SpeechEngineDefines.PARAMS_KEY_AUDIO_STREAM_TYPE_INT, |
|
||||
SpeechEngineDefines.AUDIO_STREAM_TYPE_MEDIA |
|
||||
) |
|
||||
|
|
||||
// 3.10 返回音频数据 |
|
||||
mSpeechEngine?.setOptionInt( |
|
||||
mSpeechEngineHandler, |
|
||||
SpeechEngineDefines.PARAMS_KEY_TTS_DATA_CALLBACK_MODE_INT, |
|
||||
2 |
|
||||
) |
|
||||
|
|
||||
// 4. 初始化引擎实例 |
|
||||
val ret = mSpeechEngine?.initEngine(mSpeechEngineHandler) ?: -1 |
|
||||
if (ret != SpeechEngineDefines.ERR_NO_ERROR) { |
|
||||
Log.e(TAG, "初始化引擎失败: $ret") |
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
// 设置回调监听器 |
|
||||
mSpeechEngine?.setListener(this) |
|
||||
|
|
||||
isInitialized = true |
|
||||
Log.d(TAG, "火山语音TTS引擎初始化成功") |
|
||||
return true |
|
||||
} catch (e: Exception) { |
|
||||
Log.e(TAG, "火山语音TTS引擎初始化失败: ${e.message}") |
|
||||
e.printStackTrace() |
|
||||
return false |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 直接合成并播放文本 |
|
||||
* |
|
||||
* @param text 要合成的文本 |
|
||||
* @param voiceType 语音类型 |
|
||||
* @return 是否成功开始播放 |
|
||||
*/ |
|
||||
fun speakText(text: String, voiceType: String): Boolean { |
|
||||
if (!isInitialized) { |
|
||||
Log.e(TAG, "TTS引擎尚未初始化") |
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
try { |
|
||||
Log.d(TAG, "开始直接播放文本: $text,使用语音类型: $voiceType") |
|
||||
|
|
||||
// 先停止当前播放 |
|
||||
try { |
|
||||
Log.d(TAG, "停止当前播放") |
|
||||
stopSpeaking() |
|
||||
|
|
||||
// 添加短暂延迟,确保引擎状态已重置 |
|
||||
Thread.sleep(100) |
|
||||
} catch (e: Exception) { |
|
||||
Log.e(TAG, "停止当前播放失败: ${e.message}") |
|
||||
// 继续尝试新的播放 |
|
||||
} |
|
||||
|
|
||||
// 设置发音人 |
|
||||
mSpeechEngine?.setOptionString( |
|
||||
mSpeechEngineHandler, |
|
||||
SpeechEngineDefines.PARAMS_KEY_TTS_VOICE_ONLINE_STRING, |
|
||||
"other" |
|
||||
) |
|
||||
mSpeechEngine?.setOptionString( |
|
||||
mSpeechEngineHandler, |
|
||||
SpeechEngineDefines.PARAMS_KEY_TTS_VOICE_TYPE_ONLINE_STRING, |
|
||||
voiceType |
|
||||
) |
|
||||
|
|
||||
// 设置文本 |
|
||||
mSpeechEngine?.setOptionString( |
|
||||
mSpeechEngineHandler, |
|
||||
SpeechEngineDefines.PARAMS_KEY_TTS_TEXT_STRING, |
|
||||
text |
|
||||
) |
|
||||
|
|
||||
// 启动引擎,开始合成并播放 |
|
||||
val ret = mSpeechEngine?.sendDirective(mSpeechEngineHandler, SpeechEngineDefines.DIRECTIVE_START_ENGINE, "") |
|
||||
if (ret != SpeechEngineDefines.ERR_NO_ERROR) { |
|
||||
Log.e(TAG, "启动引擎失败: $ret") |
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
Log.d(TAG, "成功开始播放") |
|
||||
return true |
|
||||
} catch (e: Exception) { |
|
||||
Log.e(TAG, "直接播放文本异常: ${e.message}") |
|
||||
return false |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 停止播放 |
|
||||
* |
|
||||
* @return 是否成功停止播放 |
|
||||
*/ |
|
||||
fun stopSpeaking(): Boolean { |
|
||||
if (!isInitialized) { |
|
||||
Log.e(TAG, "TTS引擎尚未初始化") |
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
try { |
|
||||
Log.d(TAG, "停止播放") |
|
||||
|
|
||||
// 停止引擎 |
|
||||
val ret = mSpeechEngine?.sendDirective(mSpeechEngineHandler, SpeechEngineDefines.DIRECTIVE_STOP_ENGINE, "") |
|
||||
if (ret != SpeechEngineDefines.ERR_NO_ERROR) { |
|
||||
Log.e(TAG, "停止引擎失败: $ret") |
|
||||
|
|
||||
// 尝试再次停止引擎 |
|
||||
try { |
|
||||
Log.d(TAG, "尝试再次停止引擎") |
|
||||
mSpeechEngine?.sendDirective(mSpeechEngineHandler, SpeechEngineDefines.DIRECTIVE_STOP_ENGINE, "") |
|
||||
Log.d(TAG, "引擎已停止") |
|
||||
} catch (e: Exception) { |
|
||||
Log.e(TAG, "再次停止引擎失败: ${e.message}") |
|
||||
} |
|
||||
|
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
// 确保引擎完全停止 |
|
||||
try { |
|
||||
Log.d(TAG, "确保引擎完全停止") |
|
||||
val stopAgainRet = mSpeechEngine?.sendDirective(mSpeechEngineHandler, SpeechEngineDefines.DIRECTIVE_STOP_ENGINE, "") |
|
||||
if (stopAgainRet != SpeechEngineDefines.ERR_NO_ERROR) { |
|
||||
Log.e(TAG, "再次停止引擎失败: $stopAgainRet") |
|
||||
} else { |
|
||||
Log.d(TAG, "引擎已完全停止") |
|
||||
} |
|
||||
} catch (e: Exception) { |
|
||||
Log.e(TAG, "再次停止引擎异常: ${e.message}") |
|
||||
} |
|
||||
|
|
||||
return true |
|
||||
} catch (e: Exception) { |
|
||||
Log.e(TAG, "停止播放异常: ${e.message}") |
|
||||
e.printStackTrace() |
|
||||
|
|
||||
// 尝试再次停止引擎 |
|
||||
try { |
|
||||
Log.d(TAG, "尝试再次停止引擎") |
|
||||
mSpeechEngine?.sendDirective(mSpeechEngineHandler, SpeechEngineDefines.DIRECTIVE_STOP_ENGINE, "") |
|
||||
Log.d(TAG, "引擎已停止") |
|
||||
} catch (e: Exception) { |
|
||||
Log.e(TAG, "再次停止引擎失败: ${e.message}") |
|
||||
} |
|
||||
|
|
||||
return false |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 设置播放速度 |
|
||||
* |
|
||||
* @param speed 播放速度,范围0.5-2.0 |
|
||||
* @return 是否设置成功 |
|
||||
*/ |
|
||||
fun setSpeed(speed: Float): Boolean { |
|
||||
if (!isInitialized) { |
|
||||
Log.e(TAG, "TTS引擎尚未初始化") |
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
try { |
|
||||
Log.d(TAG, "设置播放速度: $speed") |
|
||||
|
|
||||
// 设置播放速度 (使用新版本的API) |
|
||||
mSpeechEngine?.setOptionDouble( |
|
||||
SpeechEngineDefines.PARAMS_KEY_TTS_SPEED_RATIO_DOUBLE, |
|
||||
speed.toDouble() |
|
||||
) |
|
||||
|
|
||||
return true |
|
||||
} catch (e: Exception) { |
|
||||
Log.e(TAG, "设置播放速度异常: ${e.message}") |
|
||||
return false |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 设置播放音量 |
|
||||
* |
|
||||
* @param volume 播放音量,范围0.0-1.0 |
|
||||
* @return 是否设置成功 |
|
||||
*/ |
|
||||
fun setVolume(volume: Float): Boolean { |
|
||||
if (!isInitialized) { |
|
||||
Log.e(TAG, "TTS引擎尚未初始化") |
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
try { |
|
||||
Log.d(TAG, "设置播放音量: $volume") |
|
||||
|
|
||||
// 设置播放音量 (使用新版本的API) |
|
||||
mSpeechEngine?.setOptionDouble( |
|
||||
SpeechEngineDefines.PARAMS_KEY_TTS_VOLUME_RATIO_DOUBLE, |
|
||||
volume.toDouble() |
|
||||
) |
|
||||
|
|
||||
return true |
|
||||
} catch (e: Exception) { |
|
||||
Log.e(TAG, "设置播放音量异常: ${e.message}") |
|
||||
return false |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 设置播放状态回调 |
|
||||
* |
|
||||
* @param callback 播放状态回调 |
|
||||
*/ |
|
||||
fun setPlaybackCallback(callback: PlaybackCallback) { |
|
||||
mPlaybackCallback = callback |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 释放资源 |
|
||||
*/ |
|
||||
fun dispose() { |
|
||||
try { |
|
||||
if (mSpeechEngineHandler != -1L) { |
|
||||
mSpeechEngine?.sendDirective(mSpeechEngineHandler, SpeechEngineDefines.DIRECTIVE_STOP_ENGINE, "") |
|
||||
mSpeechEngine?.destroyEngine(mSpeechEngineHandler) |
|
||||
mSpeechEngineHandler = -1 |
|
||||
} |
|
||||
mSpeechEngine = null |
|
||||
isInitialized = false |
|
||||
Log.d(TAG, "火山语音合成引擎已释放") |
|
||||
} catch (e: Exception) { |
|
||||
Log.e(TAG, "释放火山语音合成引擎失败: ${e.message}") |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 实现SpeechListener接口 |
|
||||
override fun onSpeechMessage(type: Int, data: ByteArray, len: Int) { |
|
||||
val stdData = String(data) |
|
||||
|
|
||||
when (type) { |
|
||||
SpeechEngineDefines.MESSAGE_TYPE_ENGINE_START -> { |
|
||||
Log.d(TAG, "引擎启动成功: $stdData") |
|
||||
} |
|
||||
SpeechEngineDefines.MESSAGE_TYPE_ENGINE_STOP -> { |
|
||||
Log.d(TAG, "引擎已停止: $stdData") |
|
||||
} |
|
||||
SpeechEngineDefines.MESSAGE_TYPE_ENGINE_ERROR -> { |
|
||||
Log.e(TAG, "引擎错误: $stdData") |
|
||||
// 通知播放错误 |
|
||||
mPlaybackCallback?.onPlaybackError("引擎错误: $stdData") |
|
||||
} |
|
||||
SpeechEngineDefines.MESSAGE_TYPE_TTS_SYNTHESIS_BEGIN -> { |
|
||||
Log.d(TAG, "合成开始: $stdData") |
|
||||
// 通知合成开始 |
|
||||
mPlaybackCallback?.onSynthesisBegin() |
|
||||
} |
|
||||
SpeechEngineDefines.MESSAGE_TYPE_TTS_SYNTHESIS_END -> { |
|
||||
Log.d(TAG, "合成结束: $stdData") |
|
||||
// 通知合成结束 |
|
||||
mPlaybackCallback?.onSynthesisEnd() |
|
||||
} |
|
||||
SpeechEngineDefines.MESSAGE_TYPE_TTS_START_PLAYING -> { |
|
||||
Log.d(TAG, "开始播放: $stdData") |
|
||||
// 通知播放开始 |
|
||||
mPlaybackCallback?.onPlaybackStarted() |
|
||||
} |
|
||||
SpeechEngineDefines.MESSAGE_TYPE_TTS_FINISH_PLAYING -> { |
|
||||
Log.d(TAG, "播放结束: $stdData") |
|
||||
// 通知播放结束 |
|
||||
mPlaybackCallback?.onPlaybackCompleted() |
|
||||
} |
|
||||
SpeechEngineDefines.MESSAGE_TYPE_TTS_AUDIO_DATA -> { |
|
||||
Log.d(TAG, "收到音频数据") |
|
||||
} |
|
||||
SpeechEngineDefines.MESSAGE_TYPE_TTS_PLAYBACK_PROGRESS -> { |
|
||||
Log.d(TAG, "播放进度: $stdData") |
|
||||
} |
|
||||
SpeechEngineDefines.MESSAGE_TYPE_TTS_SYNTHESIS_MODE_TOGGLE -> { |
|
||||
Log.d(TAG, "合成模式切换: $stdData") |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 播放状态回调接口 |
|
||||
*/ |
|
||||
interface PlaybackCallback { |
|
||||
fun onPlaybackStarted() |
|
||||
fun onPlaybackCompleted() |
|
||||
fun onPlaybackError(error: String) |
|
||||
|
|
||||
// 新增合成开始回调 |
|
||||
fun onSynthesisBegin() |
|
||||
|
|
||||
// 新增合成结束回调 |
|
||||
fun onSynthesisEnd() |
|
||||
} |
|
||||
} |
|
||||
@ -0,0 +1,9 @@ |
|||||
|
import 'package:get/get.dart'; |
||||
|
import '../controllers/translation_detail_controller.dart'; |
||||
|
|
||||
|
class TranslationDetailBinding extends Bindings { |
||||
|
@override |
||||
|
void dependencies() { |
||||
|
Get.lazyPut<TranslationDetailController>(() => TranslationDetailController()); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,11 @@ |
|||||
|
import 'package:get/get.dart'; |
||||
|
import '../controllers/translation_history_controller.dart'; |
||||
|
|
||||
|
class TranslationHistoryBinding extends Bindings { |
||||
|
@override |
||||
|
void dependencies() { |
||||
|
Get.lazyPut<TranslationHistoryController>( |
||||
|
() => TranslationHistoryController(), |
||||
|
); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,125 @@ |
|||||
|
import 'package:get/get.dart'; |
||||
|
import '../../../data/services/volcano_ai_service.dart'; |
||||
|
import '../controllers/translation_controller.dart'; |
||||
|
import '../controllers/translation_history_controller.dart'; |
||||
|
import 'package:get_storage/get_storage.dart'; |
||||
|
|
||||
|
class TranslationDetailController extends GetxController { |
||||
|
// 依赖注入 |
||||
|
final VolcanoAIService _aiService = Get.find<VolcanoAIService>(); |
||||
|
final TranslationHistoryController historyController = Get.find<TranslationHistoryController>(); |
||||
|
final GetStorage _storage = GetStorage(); |
||||
|
|
||||
|
// 存储键前缀 |
||||
|
static const String _summaryKeyPrefix = 'translation_summary_'; |
||||
|
|
||||
|
// 状态变量 |
||||
|
final selectedTabIndex = 0.obs; |
||||
|
final isGeneratingSummary = false.obs; |
||||
|
final summaryResult = ''.obs; |
||||
|
|
||||
|
// 当前会话信息 |
||||
|
late final TranslationItem currentItem; |
||||
|
late final List<TranslationItem> sessionItems; |
||||
|
|
||||
|
// 初始化方法 |
||||
|
void initialize(TranslationItem item, List<TranslationItem> items) { |
||||
|
currentItem = item; |
||||
|
sessionItems = items; |
||||
|
|
||||
|
// 尝试从存储中加载摘要 |
||||
|
loadSavedSummary(); |
||||
|
} |
||||
|
|
||||
|
// 加载保存的摘要 |
||||
|
void loadSavedSummary() { |
||||
|
final String? savedSummary = _storage.read<String>(_getSummaryKey()); |
||||
|
if (savedSummary != null && savedSummary.isNotEmpty) { |
||||
|
summaryResult.value = savedSummary; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 保存摘要 |
||||
|
void saveSummary(String summary) { |
||||
|
if (summary.isNotEmpty) { |
||||
|
_storage.write(_getSummaryKey(), summary); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 获取摘要的存储键 |
||||
|
String _getSummaryKey() { |
||||
|
return _summaryKeyPrefix + currentItem.sessionId; |
||||
|
} |
||||
|
|
||||
|
// 切换选项卡 |
||||
|
void switchTab(int index) { |
||||
|
selectedTabIndex.value = index; |
||||
|
} |
||||
|
|
||||
|
// 播放翻译项 |
||||
|
void playTranslationItem(TranslationItem item) { |
||||
|
historyController.playTranslationItem(item); |
||||
|
} |
||||
|
|
||||
|
// 播放文本 |
||||
|
void playText(String text) { |
||||
|
historyController.playText(text); |
||||
|
} |
||||
|
|
||||
|
// 获取合并的源文本 |
||||
|
String getCombinedSourceText() { |
||||
|
return sessionItems.map((e) => e.sourceText).join('\n\n'); |
||||
|
} |
||||
|
|
||||
|
// 获取合并的翻译文本 |
||||
|
String getCombinedTranslatedText() { |
||||
|
return sessionItems.map((e) => e.translatedText).join('\n\n'); |
||||
|
} |
||||
|
|
||||
|
// 提取摘要 |
||||
|
Future<String> extractSummary(String text) async { |
||||
|
isGeneratingSummary.value = true; |
||||
|
|
||||
|
try { |
||||
|
// 构建提示词 |
||||
|
final messages = [ |
||||
|
{'role': 'user', 'content': '请为以下文本生成一个简洁的摘要,突出重点内容:\n\n$text'}, |
||||
|
]; |
||||
|
|
||||
|
// 系统提示词 |
||||
|
const systemPrompt = ''' |
||||
|
你是一个专业的文本摘要助手,擅长从会议记录、对话和文本中提取关键信息并生成简洁的摘要。 |
||||
|
请按照以下格式生成摘要: |
||||
|
|
||||
|
1. 首先用1-2句话对整体内容进行简要概括 |
||||
|
2. 然后使用项目符号列表(•)列出关键点或重要信息 |
||||
|
3. 如果是对话内容,请特别标注出讨论的结论或达成的共识 |
||||
|
|
||||
|
请遵循以下原则: |
||||
|
1. 保留原文的核心信息和关键点 |
||||
|
2. 使用简洁、清晰的语言 |
||||
|
3. 保持客观,不添加个人观点 |
||||
|
4. 摘要总长度应该是原文的1/3或更短 |
||||
|
5. 确保列表中的每个要点都简明扼要,突出重点 |
||||
|
'''; |
||||
|
|
||||
|
// 调用AI服务生成摘要 |
||||
|
final result = await _aiService.sendMessage( |
||||
|
messages: messages, |
||||
|
systemPrompt: systemPrompt, |
||||
|
); |
||||
|
|
||||
|
summaryResult.value = result; |
||||
|
|
||||
|
// 保存摘要 |
||||
|
saveSummary(result); |
||||
|
|
||||
|
return result; |
||||
|
} catch (e) { |
||||
|
summaryResult.value = '生成摘要失败: ${e.toString()}'; |
||||
|
return '生成摘要失败: ${e.toString()}'; |
||||
|
} finally { |
||||
|
isGeneratingSummary.value = false; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,129 @@ |
|||||
|
import 'package:get/get.dart'; |
||||
|
import 'package:intl/intl.dart'; |
||||
|
import '../controllers/translation_controller.dart'; |
||||
|
|
||||
|
class TranslationHistoryController extends GetxController { |
||||
|
// 引用主翻译控制器,以访问历史记录数据 |
||||
|
final TranslationController translationController = Get.find<TranslationController>(); |
||||
|
|
||||
|
// 按日期分组的历史记录 |
||||
|
final Rx<Map<String, List<TranslationItem>>> groupedHistory = Rx<Map<String, List<TranslationItem>>>({}); |
||||
|
|
||||
|
// 排序后的日期列表 |
||||
|
final RxList<String> sortedDates = <String>[].obs; |
||||
|
|
||||
|
// 会话映射表,用于存储每个会话的所有项目 |
||||
|
final Rx<Map<String, List<TranslationItem>>> sessionMap = Rx<Map<String, List<TranslationItem>>>({}); |
||||
|
|
||||
|
@override |
||||
|
void onInit() { |
||||
|
super.onInit(); |
||||
|
// 监听翻译历史记录的变化 |
||||
|
ever(translationController.translationHistory, (_) { |
||||
|
_groupHistoryByDate(); |
||||
|
_groupHistoryBySession(); |
||||
|
}); |
||||
|
|
||||
|
// 初始分组 |
||||
|
_groupHistoryByDate(); |
||||
|
_groupHistoryBySession(); |
||||
|
} |
||||
|
|
||||
|
// 按日期对历史记录进行分组 |
||||
|
void _groupHistoryByDate() { |
||||
|
final Map<String, List<TranslationItem>> newGroupedHistory = {}; |
||||
|
|
||||
|
// 筛选出每个会话的第一条记录 |
||||
|
final List<TranslationItem> firstItemsOfSessions = translationController.translationHistory |
||||
|
.where((item) => item.isFirstInSession) |
||||
|
.toList(); |
||||
|
|
||||
|
for (var item in firstItemsOfSessions) { |
||||
|
final DateTime date = item.timestamp; |
||||
|
String key; |
||||
|
|
||||
|
final now = DateTime.now(); |
||||
|
final today = DateTime(now.year, now.month, now.day); |
||||
|
final yesterday = today.subtract(const Duration(days: 1)); |
||||
|
final twoDaysAgo = today.subtract(const Duration(days: 2)); |
||||
|
final itemDate = DateTime(date.year, date.month, date.day); |
||||
|
|
||||
|
if (itemDate == today) { |
||||
|
key = 'TODAY'; |
||||
|
} else if (itemDate == yesterday) { |
||||
|
key = '1 DAY AGO'; |
||||
|
} else if (itemDate == twoDaysAgo) { |
||||
|
key = '2 DAYS AGO'; |
||||
|
} else { |
||||
|
key = DateFormat('yyyy-MM-dd').format(date); |
||||
|
} |
||||
|
|
||||
|
if (!newGroupedHistory.containsKey(key)) { |
||||
|
newGroupedHistory[key] = []; |
||||
|
} |
||||
|
|
||||
|
newGroupedHistory[key]!.add(item); |
||||
|
} |
||||
|
|
||||
|
// 对每个日期组内的项目按时间倒序排序 |
||||
|
newGroupedHistory.forEach((key, items) { |
||||
|
items.sort((a, b) => b.timestamp.compareTo(a.timestamp)); |
||||
|
}); |
||||
|
|
||||
|
// 更新分组后的历史记录 |
||||
|
groupedHistory.value = newGroupedHistory; |
||||
|
|
||||
|
// 按日期排序 |
||||
|
final List<String> dates = newGroupedHistory.keys.toList() |
||||
|
..sort((a, b) { |
||||
|
if (a == 'TODAY') return -1; |
||||
|
if (b == 'TODAY') return 1; |
||||
|
if (a == '1 DAY AGO') return -1; |
||||
|
if (b == '1 DAY AGO') return 1; |
||||
|
if (a == '2 DAYS AGO') return -1; |
||||
|
if (b == '2 DAYS AGO') return 1; |
||||
|
return b.compareTo(a); // 降序排列其他日期 |
||||
|
}); |
||||
|
|
||||
|
sortedDates.value = dates; |
||||
|
} |
||||
|
|
||||
|
// 按会话ID对历史记录进行分组 |
||||
|
void _groupHistoryBySession() { |
||||
|
final Map<String, List<TranslationItem>> newSessionMap = {}; |
||||
|
|
||||
|
for (var item in translationController.translationHistory) { |
||||
|
if (!newSessionMap.containsKey(item.sessionId)) { |
||||
|
newSessionMap[item.sessionId] = []; |
||||
|
} |
||||
|
|
||||
|
newSessionMap[item.sessionId]!.add(item); |
||||
|
} |
||||
|
|
||||
|
// 对每个会话内的项目按时间升序排序 |
||||
|
newSessionMap.forEach((key, items) { |
||||
|
items.sort((a, b) => a.timestamp.compareTo(b.timestamp)); |
||||
|
}); |
||||
|
|
||||
|
// 更新会话映射表 |
||||
|
sessionMap.value = newSessionMap; |
||||
|
} |
||||
|
|
||||
|
// 获取指定会话的所有翻译项 |
||||
|
List<TranslationItem> getSessionItems(String sessionId) { |
||||
|
return sessionMap.value[sessionId] ?? []; |
||||
|
} |
||||
|
|
||||
|
// 播放翻译项 |
||||
|
void playTranslationItem(TranslationItem item) { |
||||
|
translationController.playTranslationItem(item); |
||||
|
} |
||||
|
|
||||
|
// 播放指定文本 |
||||
|
void playText(String text) { |
||||
|
translationController.playTranslatedText(text); |
||||
|
} |
||||
|
|
||||
|
// 检查是否有历史记录 |
||||
|
bool get hasHistory => translationController.translationHistory.isNotEmpty; |
||||
|
} |
||||
@ -0,0 +1,491 @@ |
|||||
|
import 'package:flutter/material.dart'; |
||||
|
import 'package:get/get.dart'; |
||||
|
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
||||
|
import '../controllers/translation_controller.dart'; |
||||
|
import '../controllers/translation_history_controller.dart'; |
||||
|
import '../controllers/translation_detail_controller.dart'; |
||||
|
import 'package:intl/intl.dart'; |
||||
|
import 'package:flutter/services.dart'; |
||||
|
import 'package:share_plus/share_plus.dart'; |
||||
|
|
||||
|
class TranslationDetailView extends GetView<TranslationDetailController> { |
||||
|
final TranslationItem item; |
||||
|
final List<TranslationItem> sessionItems; |
||||
|
final TranslationHistoryController historyController; |
||||
|
|
||||
|
const TranslationDetailView({ |
||||
|
Key? key, |
||||
|
required this.item, |
||||
|
required this.sessionItems, |
||||
|
required this.historyController, |
||||
|
}) : super(key: key); |
||||
|
|
||||
|
@override |
||||
|
Widget build(BuildContext context) { |
||||
|
// 初始化控制器 |
||||
|
controller.initialize(item, sessionItems); |
||||
|
|
||||
|
return Scaffold( |
||||
|
backgroundColor: Color(0xFFF5F5F8), |
||||
|
appBar: AppBar( |
||||
|
backgroundColor: Color(0xFFF5F5F8), |
||||
|
elevation: 0, |
||||
|
leading: IconButton( |
||||
|
icon: Icon( |
||||
|
Icons.arrow_back_ios, |
||||
|
color: Colors.black, |
||||
|
size: 20.sp, |
||||
|
), |
||||
|
onPressed: () => Get.back(), |
||||
|
), |
||||
|
title: Text( |
||||
|
'Detail', |
||||
|
style: TextStyle( |
||||
|
color: Colors.black, |
||||
|
fontSize: 24.sp, |
||||
|
fontWeight: FontWeight.bold, |
||||
|
), |
||||
|
), |
||||
|
centerTitle: true, |
||||
|
actions: [ |
||||
|
IconButton( |
||||
|
icon: Icon( |
||||
|
Icons.share, |
||||
|
color: Colors.black, |
||||
|
size: 22.sp, |
||||
|
), |
||||
|
onPressed: () { |
||||
|
_shareContent(); |
||||
|
}, |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
body: Column( |
||||
|
children: [ |
||||
|
// 选项卡按钮 |
||||
|
Padding( |
||||
|
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 12.h), |
||||
|
child: Row( |
||||
|
children: [ |
||||
|
Expanded( |
||||
|
child: Obx(() => _buildTabButton( |
||||
|
title: '原始记录', |
||||
|
isSelected: controller.selectedTabIndex.value == 0, |
||||
|
onTap: () => controller.switchTab(0), |
||||
|
)), |
||||
|
), |
||||
|
SizedBox(width: 12.w), |
||||
|
Expanded( |
||||
|
child: Obx(() => _buildTabButton( |
||||
|
title: '记录摘要', |
||||
|
isSelected: controller.selectedTabIndex.value == 1, |
||||
|
onTap: () => controller.switchTab(1), |
||||
|
)), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
|
||||
|
// 内容区域 |
||||
|
Expanded( |
||||
|
child: SingleChildScrollView( |
||||
|
padding: EdgeInsets.symmetric(horizontal: 16.w), |
||||
|
child: Obx(() { |
||||
|
return Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
// 会话时间 |
||||
|
Padding( |
||||
|
padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 8.h), |
||||
|
child: Text( |
||||
|
DateFormat('yyyy-MM-dd HH:mm').format(item.timestamp), |
||||
|
style: TextStyle( |
||||
|
fontSize: 14.sp, |
||||
|
fontWeight: FontWeight.w600, |
||||
|
color: Colors.grey[600], |
||||
|
letterSpacing: 1.0, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
SizedBox(height: 8.h), |
||||
|
|
||||
|
// 根据选中的选项卡显示不同内容 |
||||
|
if (controller.selectedTabIndex.value == 0) |
||||
|
// 原始记录 - 显示所有翻译项 |
||||
|
...sessionItems.map((sessionItem) => _buildSessionItemWidget(sessionItem)).toList() |
||||
|
else |
||||
|
// 记录总结 - 显示合并后的内容 |
||||
|
_buildSummaryWidget(), |
||||
|
|
||||
|
SizedBox(height: 24.h), |
||||
|
], |
||||
|
); |
||||
|
}), |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// 构建选项卡按钮 |
||||
|
Widget _buildTabButton({ |
||||
|
required String title, |
||||
|
required bool isSelected, |
||||
|
required VoidCallback onTap, |
||||
|
}) { |
||||
|
return GestureDetector( |
||||
|
onTap: onTap, |
||||
|
child: Container( |
||||
|
height: 40.h, |
||||
|
decoration: BoxDecoration( |
||||
|
color: isSelected ? Colors.blue : Colors.white, |
||||
|
borderRadius: BorderRadius.circular(20.r), |
||||
|
boxShadow: [ |
||||
|
BoxShadow( |
||||
|
color: Colors.black.withAlpha(8), |
||||
|
blurRadius: 4, |
||||
|
offset: Offset(0, 1), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
alignment: Alignment.center, |
||||
|
child: Text( |
||||
|
title, |
||||
|
style: TextStyle( |
||||
|
fontSize: 15.sp, |
||||
|
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, |
||||
|
color: isSelected ? Colors.white : Colors.black87, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// 构建会话详情中的翻译项 |
||||
|
Widget _buildSessionItemWidget(TranslationItem item) { |
||||
|
return Container( |
||||
|
width: double.infinity, |
||||
|
margin: EdgeInsets.only(bottom: 16.h), |
||||
|
decoration: BoxDecoration( |
||||
|
color: Colors.white, |
||||
|
borderRadius: BorderRadius.circular(16.r), |
||||
|
boxShadow: [ |
||||
|
BoxShadow( |
||||
|
color: Colors.black.withAlpha(8), |
||||
|
blurRadius: 8, |
||||
|
offset: Offset(0, 2), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
// 源文本部分 |
||||
|
Padding( |
||||
|
padding: EdgeInsets.fromLTRB(20.r, 20.r, 20.r, 10.r), |
||||
|
child: Text( |
||||
|
item.sourceText, |
||||
|
style: TextStyle( |
||||
|
fontSize: 15.sp, |
||||
|
color: Colors.black, |
||||
|
fontWeight: FontWeight.w500, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
// 翻译文本部分 |
||||
|
Padding( |
||||
|
padding: EdgeInsets.fromLTRB(20.r, 10.r, 20.r, 16.r), |
||||
|
child: Row( |
||||
|
crossAxisAlignment: CrossAxisAlignment.end, |
||||
|
children: [ |
||||
|
Expanded( |
||||
|
child: Text( |
||||
|
item.translatedText, |
||||
|
style: TextStyle( |
||||
|
fontSize: 14.sp, |
||||
|
color: Colors.grey[700], |
||||
|
fontWeight: FontWeight.normal, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
SizedBox(width: 12.w), |
||||
|
// 播放按钮 |
||||
|
GestureDetector( |
||||
|
onTap: () { |
||||
|
controller.playTranslationItem(item); |
||||
|
}, |
||||
|
child: Container( |
||||
|
width: 28.w, |
||||
|
height: 28.h, |
||||
|
decoration: BoxDecoration( |
||||
|
color: Colors.blue.withAlpha(10), |
||||
|
shape: BoxShape.circle, |
||||
|
), |
||||
|
child: Icon( |
||||
|
Icons.volume_up, |
||||
|
color: Colors.blue, |
||||
|
size: 16.sp, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// 构建总结视图 |
||||
|
Widget _buildSummaryWidget() { |
||||
|
final String combinedSourceText = controller.getCombinedSourceText(); |
||||
|
|
||||
|
return Column( |
||||
|
children: [ |
||||
|
// 摘要内容容器 |
||||
|
Container( |
||||
|
width: double.infinity, |
||||
|
margin: EdgeInsets.only(bottom: 16.h), |
||||
|
decoration: BoxDecoration( |
||||
|
color: Colors.white, |
||||
|
borderRadius: BorderRadius.circular(16.r), |
||||
|
boxShadow: [ |
||||
|
BoxShadow( |
||||
|
color: Colors.black.withAlpha(8), |
||||
|
blurRadius: 8, |
||||
|
offset: Offset(0, 2), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
child: Obx(() { |
||||
|
if (controller.summaryResult.value.isEmpty) { |
||||
|
// 如果没有摘要,显示提示信息 |
||||
|
return Padding( |
||||
|
padding: EdgeInsets.all(24.r), |
||||
|
child: Center( |
||||
|
child: Text( |
||||
|
'点击下方按钮生成会话摘要', |
||||
|
style: TextStyle( |
||||
|
fontSize: 15.sp, |
||||
|
color: Colors.grey[600], |
||||
|
fontStyle: FontStyle.italic, |
||||
|
), |
||||
|
textAlign: TextAlign.center, |
||||
|
), |
||||
|
), |
||||
|
); |
||||
|
} else { |
||||
|
// 如果有摘要,显示摘要内容 |
||||
|
return Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Padding( |
||||
|
padding: EdgeInsets.all(20.r), |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Text( |
||||
|
'摘要内容', |
||||
|
style: TextStyle( |
||||
|
fontSize: 16.sp, |
||||
|
fontWeight: FontWeight.bold, |
||||
|
color: Colors.black, |
||||
|
), |
||||
|
), |
||||
|
SizedBox(height: 12.h), |
||||
|
Text( |
||||
|
controller.summaryResult.value, |
||||
|
style: TextStyle( |
||||
|
fontSize: 15.sp, |
||||
|
color: Colors.black87, |
||||
|
height: 1.5, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
Divider( |
||||
|
height: 1, |
||||
|
thickness: 0.5, |
||||
|
color: Colors.grey[200], |
||||
|
), |
||||
|
Padding( |
||||
|
padding: EdgeInsets.symmetric(horizontal: 12.r, vertical: 8.r), |
||||
|
child: Row( |
||||
|
mainAxisAlignment: MainAxisAlignment.end, |
||||
|
children: [ |
||||
|
// 播放按钮 |
||||
|
GestureDetector( |
||||
|
onTap: () { |
||||
|
controller.playText(controller.summaryResult.value); |
||||
|
}, |
||||
|
child: Container( |
||||
|
padding: EdgeInsets.all(8.r), |
||||
|
decoration: BoxDecoration( |
||||
|
color: Colors.blue.withAlpha(10), |
||||
|
shape: BoxShape.circle, |
||||
|
), |
||||
|
child: Icon( |
||||
|
Icons.volume_up, |
||||
|
color: Colors.blue, |
||||
|
size: 18.sp, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
SizedBox(width: 12.w), |
||||
|
// 复制按钮 |
||||
|
GestureDetector( |
||||
|
onTap: () { |
||||
|
// 复制摘要到剪贴板 |
||||
|
Clipboard.setData(ClipboardData(text: controller.summaryResult.value)); |
||||
|
Get.snackbar( |
||||
|
'已复制', |
||||
|
'摘要已复制到剪贴板', |
||||
|
snackPosition: SnackPosition.BOTTOM, |
||||
|
duration: Duration(seconds: 2), |
||||
|
); |
||||
|
}, |
||||
|
child: Container( |
||||
|
padding: EdgeInsets.all(8.r), |
||||
|
decoration: BoxDecoration( |
||||
|
color: Colors.blue.withAlpha(10), |
||||
|
shape: BoxShape.circle, |
||||
|
), |
||||
|
child: Icon( |
||||
|
Icons.copy, |
||||
|
color: Colors.blue, |
||||
|
size: 18.sp, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
); |
||||
|
} |
||||
|
}), |
||||
|
), |
||||
|
|
||||
|
// 提取摘要按钮 |
||||
|
Obx(() => Container( |
||||
|
width: double.infinity, |
||||
|
margin: EdgeInsets.only(bottom: 16.h), |
||||
|
child: ElevatedButton( |
||||
|
onPressed: controller.isGeneratingSummary.value || controller.summaryResult.value.isNotEmpty |
||||
|
? null |
||||
|
: () => _extractSummary(combinedSourceText), |
||||
|
style: ElevatedButton.styleFrom( |
||||
|
backgroundColor: Colors.blue, |
||||
|
foregroundColor: Colors.white, |
||||
|
padding: EdgeInsets.symmetric(vertical: 12.h), |
||||
|
shape: RoundedRectangleBorder( |
||||
|
borderRadius: BorderRadius.circular(16.r), |
||||
|
), |
||||
|
elevation: 0, |
||||
|
disabledBackgroundColor: controller.isGeneratingSummary.value |
||||
|
? Colors.blue.withAlpha(128) |
||||
|
: Colors.grey.withAlpha(128), |
||||
|
), |
||||
|
child: controller.isGeneratingSummary.value |
||||
|
? Row( |
||||
|
mainAxisAlignment: MainAxisAlignment.center, |
||||
|
children: [ |
||||
|
SizedBox( |
||||
|
width: 20.w, |
||||
|
height: 20.h, |
||||
|
child: CircularProgressIndicator( |
||||
|
color: Colors.white, |
||||
|
strokeWidth: 2.0, |
||||
|
), |
||||
|
), |
||||
|
SizedBox(width: 12.w), |
||||
|
Text( |
||||
|
'生成中...', |
||||
|
style: TextStyle( |
||||
|
fontSize: 16.sp, |
||||
|
fontWeight: FontWeight.w600, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
) |
||||
|
: Text( |
||||
|
controller.summaryResult.value.isEmpty ? '提取摘要' : '已生成摘要', |
||||
|
style: TextStyle( |
||||
|
fontSize: 16.sp, |
||||
|
fontWeight: FontWeight.w600, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
)), |
||||
|
|
||||
|
// 重新生成按钮 |
||||
|
Obx(() { |
||||
|
if (controller.summaryResult.value.isEmpty || controller.isGeneratingSummary.value) { |
||||
|
return SizedBox.shrink(); |
||||
|
} |
||||
|
|
||||
|
return Container( |
||||
|
width: double.infinity, |
||||
|
margin: EdgeInsets.only(bottom: 16.h), |
||||
|
child: OutlinedButton( |
||||
|
onPressed: () => _extractSummary(combinedSourceText), |
||||
|
style: OutlinedButton.styleFrom( |
||||
|
foregroundColor: Colors.blue, |
||||
|
side: BorderSide(color: Colors.blue), |
||||
|
padding: EdgeInsets.symmetric(vertical: 12.h), |
||||
|
shape: RoundedRectangleBorder( |
||||
|
borderRadius: BorderRadius.circular(16.r), |
||||
|
), |
||||
|
), |
||||
|
child: Text( |
||||
|
'重新生成', |
||||
|
style: TextStyle( |
||||
|
fontSize: 16.sp, |
||||
|
fontWeight: FontWeight.w600, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
); |
||||
|
}), |
||||
|
], |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// 提取摘要方法 |
||||
|
void _extractSummary(String text) async { |
||||
|
await controller.extractSummary(text); |
||||
|
} |
||||
|
|
||||
|
// 分享内容方法 |
||||
|
void _shareContent() { |
||||
|
String shareText = ''; |
||||
|
|
||||
|
if (controller.selectedTabIndex.value == 0) { |
||||
|
// 分享原始记录 |
||||
|
final String sourceText = controller.getCombinedSourceText(); |
||||
|
final String translatedText = controller.getCombinedTranslatedText(); |
||||
|
shareText = '原文:\n$sourceText\n\n译文:\n$translatedText'; |
||||
|
} else { |
||||
|
// 分享摘要 |
||||
|
if (controller.summaryResult.value.isNotEmpty) { |
||||
|
shareText = '会话摘要:\n${controller.summaryResult.value}'; |
||||
|
} else { |
||||
|
Get.snackbar( |
||||
|
'无法分享', |
||||
|
'请先生成摘要', |
||||
|
snackPosition: SnackPosition.BOTTOM, |
||||
|
duration: Duration(seconds: 2), |
||||
|
); |
||||
|
return; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 调用系统分享 |
||||
|
Share.share( |
||||
|
shareText, |
||||
|
subject: '翻译会话记录 - ${DateFormat('yyyy-MM-dd HH:mm').format(controller.currentItem.timestamp)}', |
||||
|
); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,244 @@ |
|||||
|
import 'package:flutter/material.dart'; |
||||
|
import 'package:get/get.dart'; |
||||
|
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
||||
|
import '../controllers/translation_controller.dart'; |
||||
|
import '../controllers/translation_history_controller.dart'; |
||||
|
import '../bindings/translation_detail_binding.dart'; |
||||
|
import 'translation_detail_view.dart'; |
||||
|
|
||||
|
class TranslationHistoryView extends GetView<TranslationHistoryController> { |
||||
|
const TranslationHistoryView({Key? key}) : super(key: key); |
||||
|
|
||||
|
@override |
||||
|
Widget build(BuildContext context) { |
||||
|
return Scaffold( |
||||
|
backgroundColor: Color(0xFFF5F5F8), |
||||
|
appBar: AppBar( |
||||
|
backgroundColor: Color(0xFFF5F5F8), |
||||
|
elevation: 0, |
||||
|
leading: IconButton( |
||||
|
icon: Icon( |
||||
|
Icons.arrow_back_ios, |
||||
|
color: Colors.black, |
||||
|
size: 20.sp, |
||||
|
), |
||||
|
onPressed: () => Get.back(), |
||||
|
), |
||||
|
title: Text( |
||||
|
'History', |
||||
|
style: TextStyle( |
||||
|
color: Colors.black, |
||||
|
fontSize: 24.sp, |
||||
|
fontWeight: FontWeight.bold, |
||||
|
), |
||||
|
), |
||||
|
centerTitle: true, |
||||
|
actions: [ |
||||
|
IconButton( |
||||
|
icon: Icon( |
||||
|
Icons.delete_outline, |
||||
|
color: Colors.black, |
||||
|
size: 24.sp, |
||||
|
), |
||||
|
onPressed: () { |
||||
|
_showClearHistoryConfirmDialog(); |
||||
|
}, |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
body: Obx(() { |
||||
|
if (!controller.hasHistory) { |
||||
|
return Center( |
||||
|
child: Text( |
||||
|
'暂无历史记录', |
||||
|
style: TextStyle( |
||||
|
fontSize: 16.sp, |
||||
|
color: Colors.grey, |
||||
|
), |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
return ListView.builder( |
||||
|
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h), |
||||
|
itemCount: controller.sortedDates.length, |
||||
|
itemBuilder: (context, index) { |
||||
|
final date = controller.sortedDates[index]; |
||||
|
final items = controller.groupedHistory.value[date]!; |
||||
|
|
||||
|
return Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Padding( |
||||
|
padding: EdgeInsets.only(top: 24.h, bottom: 12.h, left: 12.w), |
||||
|
child: Text( |
||||
|
date, |
||||
|
style: TextStyle( |
||||
|
fontSize: 14.sp, |
||||
|
fontWeight: FontWeight.w600, |
||||
|
color: Colors.grey[600], |
||||
|
letterSpacing: 1.0, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
_buildDateGroupContainer(items), |
||||
|
], |
||||
|
); |
||||
|
}, |
||||
|
); |
||||
|
}), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// 构建日期组容器 |
||||
|
Widget _buildDateGroupContainer(List<TranslationItem> items) { |
||||
|
return Container( |
||||
|
margin: EdgeInsets.only(bottom: 16.h), |
||||
|
decoration: BoxDecoration( |
||||
|
color: Colors.white, |
||||
|
borderRadius: BorderRadius.circular(16.r), |
||||
|
boxShadow: [ |
||||
|
BoxShadow( |
||||
|
color: Colors.black.withAlpha(8), |
||||
|
blurRadius: 8, |
||||
|
offset: Offset(0, 2), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
child: ListView.separated( |
||||
|
physics: NeverScrollableScrollPhysics(), |
||||
|
shrinkWrap: true, |
||||
|
padding: EdgeInsets.zero, |
||||
|
itemCount: items.length, |
||||
|
separatorBuilder: (context, index) => Divider( |
||||
|
height: 1, |
||||
|
thickness: 0.5, |
||||
|
color: Colors.grey[200], |
||||
|
indent: 0, |
||||
|
endIndent: 0, |
||||
|
), |
||||
|
itemBuilder: (context, index) { |
||||
|
return _buildHistoryItemInGroup(items[index]); |
||||
|
}, |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// 构建组内的历史记录项 |
||||
|
Widget _buildHistoryItemInGroup(TranslationItem item) { |
||||
|
return GestureDetector( |
||||
|
onTap: () { |
||||
|
// 点击查看详情 |
||||
|
_showTranslationDetail(item); |
||||
|
}, |
||||
|
child: Stack( |
||||
|
children: [ |
||||
|
Padding( |
||||
|
padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 16.h), |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
// 源文本 |
||||
|
Text( |
||||
|
item.sourceText, |
||||
|
style: TextStyle( |
||||
|
fontSize: 15.sp, |
||||
|
fontWeight: FontWeight.w500, |
||||
|
color: Colors.black, |
||||
|
), |
||||
|
maxLines: 2, |
||||
|
overflow: TextOverflow.ellipsis, |
||||
|
), |
||||
|
SizedBox(height: 6.h), |
||||
|
// 翻译文本 |
||||
|
Text( |
||||
|
item.translatedText, |
||||
|
style: TextStyle( |
||||
|
fontSize: 14.sp, |
||||
|
color: Colors.grey[700], |
||||
|
), |
||||
|
maxLines: 2, |
||||
|
overflow: TextOverflow.ellipsis, |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
Positioned( |
||||
|
right: 12.w, |
||||
|
top: 0, |
||||
|
bottom: 0, |
||||
|
child: Icon( |
||||
|
Icons.chevron_right, |
||||
|
color: Colors.grey[350], |
||||
|
size: 20.sp, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
void _showTranslationDetail(TranslationItem item) { |
||||
|
// 获取该会话的所有翻译项 |
||||
|
final List<TranslationItem> sessionItems = controller.getSessionItems(item.sessionId); |
||||
|
|
||||
|
Get.to( |
||||
|
() => TranslationDetailView( |
||||
|
item: item, |
||||
|
sessionItems: sessionItems, |
||||
|
historyController: controller, |
||||
|
), |
||||
|
binding: TranslationDetailBinding(), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
void _showClearHistoryConfirmDialog() { |
||||
|
Get.dialog( |
||||
|
AlertDialog( |
||||
|
title: Text( |
||||
|
'清除历史记录', |
||||
|
style: TextStyle( |
||||
|
fontSize: 18.sp, |
||||
|
fontWeight: FontWeight.bold, |
||||
|
), |
||||
|
), |
||||
|
content: Text( |
||||
|
'确定要清除所有翻译历史记录吗?此操作无法撤销。', |
||||
|
style: TextStyle( |
||||
|
fontSize: 16.sp, |
||||
|
), |
||||
|
), |
||||
|
actions: [ |
||||
|
TextButton( |
||||
|
onPressed: () => Get.back(), |
||||
|
child: Text( |
||||
|
'取消', |
||||
|
style: TextStyle( |
||||
|
fontSize: 16.sp, |
||||
|
color: Colors.grey[700], |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
TextButton( |
||||
|
onPressed: () { |
||||
|
// 清除历史记录 |
||||
|
controller.translationController.clearTranslationHistory(); |
||||
|
Get.back(); |
||||
|
}, |
||||
|
child: Text( |
||||
|
'确定', |
||||
|
style: TextStyle( |
||||
|
fontSize: 16.sp, |
||||
|
color: Colors.red, |
||||
|
fontWeight: FontWeight.bold, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
shape: RoundedRectangleBorder( |
||||
|
borderRadius: BorderRadius.circular(12.r), |
||||
|
), |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue