[8.0.0]
[fea] [ai+高精地图]
@@ -0,0 +1,241 @@
|
||||
package com.mogo.och.common.module.wigets
|
||||
|
||||
/*
|
||||
* Copyright WeiLianYang
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.*
|
||||
import android.util.AttributeSet
|
||||
import android.util.Log
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.appcompat.widget.AppCompatImageView
|
||||
import com.mogo.och.common.module.R
|
||||
|
||||
|
||||
/**
|
||||
* author : WilliamYang
|
||||
* date : 2022/9/18 14:54
|
||||
* description : 可设置 圆角、外边框 的 ImageView
|
||||
*
|
||||
* 使用方式:
|
||||
*
|
||||
* 1. 使用 riv_radius 设置4个角均为圆角,且圆角值一样
|
||||
*
|
||||
* 2. 使用 riv_roundAsCircle 设置图片为圆形,使用 riv_radius 设置半径,当 riv_radius 未设置时,默认取宽高最小值的一半
|
||||
*
|
||||
* 3. 使用 riv_topLeft_radius, riv_topRight_radius, riv_bottomLeft_radius, riv_bottomRight_radius 设置4个圆角
|
||||
*
|
||||
* 4. 使用 riv_borderColor, riv_borderWidth 设置外边框颜色和宽度
|
||||
*
|
||||
* <p>
|
||||
*/
|
||||
class OchRoundImageView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) : AppCompatImageView(context, attrs, defStyleAttr) {
|
||||
|
||||
/** 绘制路径 **/
|
||||
private val path = Path()
|
||||
|
||||
/** 绘制坐标 **/
|
||||
private val rectF = RectF()
|
||||
|
||||
/** 圆角大小 **/
|
||||
private var radius = 0f
|
||||
|
||||
/** 顶部左侧圆角大小 **/
|
||||
private var topLeftRadius = 0f
|
||||
|
||||
/** 顶部右侧圆角大小 **/
|
||||
private var topRightRadius = 0f
|
||||
|
||||
/** 底部左侧圆角大小 **/
|
||||
private var bottomLeftRadius = 0f
|
||||
|
||||
/** 底部右侧圆角大小 **/
|
||||
private var bottomRightRadius = 0f
|
||||
|
||||
/** 作为圆形图片使用 **/
|
||||
private var roundAsCircle = false
|
||||
|
||||
/** 外边框颜色、宽度、画笔、路径、坐标 */
|
||||
private var borderColor = 0
|
||||
private var borderWidth = 0f
|
||||
private val borderPaint: Paint?
|
||||
private val borderPath = Path()
|
||||
private val borderRectF = RectF()
|
||||
|
||||
init {
|
||||
val ta = context.obtainStyledAttributes(attrs, R.styleable.RoundImageView)
|
||||
|
||||
roundAsCircle = ta.getBoolean(R.styleable.RoundImageView_riv_roundAsCircle, false)
|
||||
borderColor = ta.getColor(R.styleable.RoundImageView_riv_borderColor, Color.TRANSPARENT)
|
||||
borderWidth = ta.getDimension(R.styleable.RoundImageView_riv_borderWidth, 0f)
|
||||
|
||||
radius = ta.getDimension(R.styleable.RoundImageView_riv_radius, 0f)
|
||||
|
||||
topLeftRadius = ta.getDimension(R.styleable.RoundImageView_riv_topLeft_radius, 0f)
|
||||
topRightRadius = ta.getDimension(R.styleable.RoundImageView_riv_topRight_radius, 0f)
|
||||
bottomLeftRadius = ta.getDimension(R.styleable.RoundImageView_riv_bottomLeft_radius, 0f)
|
||||
bottomRightRadius = ta.getDimension(R.styleable.RoundImageView_riv_bottomRight_radius, 0f)
|
||||
|
||||
ta.recycle()
|
||||
|
||||
borderPaint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
updateBorderPaint()
|
||||
}
|
||||
|
||||
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
|
||||
super.onSizeChanged(w, h, oldw, oldh)
|
||||
|
||||
// 当作为圆形图片使用,且半径未设置时,半径将取宽高最小值的一半
|
||||
if (roundAsCircle && radius <= 0f) {
|
||||
radius = w.coerceAtMost(h) / 2f
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||
// 当作为圆形图片使用,宽高值不同,取宽高的最小值作为宽和高
|
||||
val widthSize = MeasureSpec.getSize(widthMeasureSpec)
|
||||
val heightSize = MeasureSpec.getSize(heightMeasureSpec)
|
||||
if (roundAsCircle && widthSize > 0 && heightSize > 0 && widthSize != heightSize) {
|
||||
val size = widthSize.coerceAtMost(heightSize)
|
||||
setMeasuredDimension(size, size)
|
||||
} else {
|
||||
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
val halfBorderWidth = borderWidth / 2
|
||||
if (radius > 0 || topLeftRadius > 0 || topRightRadius > 0 || bottomLeftRadius > 0 || bottomRightRadius > 0) {
|
||||
// 如果设置了圆角值
|
||||
path.reset()
|
||||
borderPath.reset()
|
||||
if (roundAsCircle) {
|
||||
path.addCircle(radius, radius, radius, Path.Direction.CW)
|
||||
} else {
|
||||
if (topLeftRadius == 0f) topLeftRadius = radius
|
||||
if (topRightRadius == 0f) topRightRadius = radius
|
||||
if (bottomLeftRadius == 0f) bottomLeftRadius = radius
|
||||
if (bottomRightRadius == 0f) bottomRightRadius = radius
|
||||
|
||||
Log.d("RoundImageView", "onDraw: topLeftRadius=$topLeftRadius, topRightRadius=$topRightRadius, bottomLeftRadius=$bottomLeftRadius, bottomRightRadius=$bottomRightRadius")
|
||||
|
||||
val radii = floatArrayOf(
|
||||
topLeftRadius, topLeftRadius, topRightRadius, topRightRadius,
|
||||
bottomRightRadius, bottomRightRadius, bottomLeftRadius, bottomLeftRadius
|
||||
)
|
||||
|
||||
|
||||
|
||||
borderRectF.set(
|
||||
paddingLeft.toFloat() + halfBorderWidth, paddingTop.toFloat() + halfBorderWidth,
|
||||
measuredWidth.toFloat() - paddingRight - halfBorderWidth,
|
||||
measuredHeight.toFloat() - paddingBottom - halfBorderWidth
|
||||
)
|
||||
|
||||
borderPath.addRoundRect(borderRectF, radii, Path.Direction.CW)
|
||||
|
||||
if (halfBorderWidth > 0) {
|
||||
radii.forEachIndexed { index, f ->
|
||||
if (f > 0) {
|
||||
radii[index] = f + halfBorderWidth
|
||||
}
|
||||
}
|
||||
}
|
||||
rectF.set(
|
||||
paddingLeft.toFloat(),
|
||||
paddingTop.toFloat(),
|
||||
measuredWidth.toFloat() - paddingRight,
|
||||
measuredHeight.toFloat() - paddingBottom
|
||||
)
|
||||
path.addRoundRect(rectF, radii, Path.Direction.CW)
|
||||
}
|
||||
|
||||
// 裁剪画布
|
||||
canvas.clipPath(path)
|
||||
}
|
||||
|
||||
super.onDraw(canvas)
|
||||
|
||||
if (borderWidth > 0 && borderPaint != null) {
|
||||
if (roundAsCircle) {
|
||||
canvas.drawCircle(radius, radius, radius - borderWidth / 2, borderPaint)
|
||||
} else {
|
||||
canvas.drawPath(borderPath, borderPaint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateBorderPaint() {
|
||||
borderPaint?.apply {
|
||||
color = borderColor
|
||||
strokeWidth = borderWidth
|
||||
style = Paint.Style.STROKE
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param radius 圆角大小,当 asCircle 为 true 时,值作为圆形图片的半径,如果为0,则将取宽高最小值的一半
|
||||
* @param borderWidth 外边框宽度
|
||||
* @param borderColor 外边框颜色
|
||||
* @param asCircle 作为圆形图片使用,默认 false
|
||||
*/
|
||||
fun setRadiusAndBorder(
|
||||
radius: Float,
|
||||
borderWidth: Float = 0f,
|
||||
@ColorInt borderColor: Int = 0,
|
||||
asCircle: Boolean = false,
|
||||
) {
|
||||
this.radius = radius
|
||||
this.borderWidth = borderWidth
|
||||
this.borderColor = borderColor
|
||||
this.roundAsCircle = asCircle
|
||||
|
||||
updateBorderPaint()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param topLeftRadius 顶部左侧圆角大小
|
||||
* @param topRightRadius 顶部右侧圆角大小
|
||||
* @param bottomLeftRadius 底部左侧圆角大小
|
||||
* @param bottomRightRadius 底部右侧圆角大小
|
||||
* @param borderWidth 外边框宽度
|
||||
* @param borderColor 外边框颜色
|
||||
*/
|
||||
fun setRadiusAndBorder(
|
||||
topLeftRadius: Float = 0f,
|
||||
topRightRadius: Float = 0f,
|
||||
bottomLeftRadius: Float = 0f,
|
||||
bottomRightRadius: Float = 0f,
|
||||
borderWidth: Float = 0f,
|
||||
@ColorInt borderColor: Int = 0
|
||||
) {
|
||||
this.topLeftRadius = topLeftRadius
|
||||
this.topRightRadius = topRightRadius
|
||||
this.bottomLeftRadius = bottomLeftRadius
|
||||
this.bottomRightRadius = bottomRightRadius
|
||||
this.borderWidth = borderWidth
|
||||
this.borderColor = borderColor
|
||||
|
||||
updateBorderPaint()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -114,4 +114,33 @@
|
||||
<attr name="BLOCK_START_Y" format="dimension" />
|
||||
</declare-styleable>
|
||||
|
||||
<declare-styleable name="RoundImageView">
|
||||
|
||||
<!-- 圆角大小,如果只设置了此值,则默认会使用其作为所有圆角的值 -->
|
||||
<attr name="riv_radius" format="dimension" />
|
||||
|
||||
<!-- 顶部左侧圆角大小 -->
|
||||
<attr name="riv_topLeft_radius" format="dimension" />
|
||||
|
||||
<!-- 顶部右侧圆角大小 -->
|
||||
<attr name="riv_topRight_radius" format="dimension" />
|
||||
|
||||
<!-- 底部左侧圆角大小 -->
|
||||
<attr name="riv_bottomLeft_radius" format="dimension" />
|
||||
|
||||
<!-- 底部右侧圆角大小 -->
|
||||
<attr name="riv_bottomRight_radius" format="dimension" />
|
||||
|
||||
<!-- 作为圆形图片,和 riv_radius 一起使用。
|
||||
如果未设置 riv_radius,半径将取宽高最小值的一半 -->
|
||||
<attr name="riv_roundAsCircle" format="boolean" />
|
||||
|
||||
<!-- 外边框颜色 -->
|
||||
<attr name="riv_borderColor" format="color" />
|
||||
|
||||
<!-- 外边框宽度 -->
|
||||
<attr name="riv_borderWidth" format="dimension" />
|
||||
|
||||
</declare-styleable>
|
||||
|
||||
</resources>
|
||||
@@ -73,6 +73,10 @@ dependencies {
|
||||
implementation project(":OCH:common:common")
|
||||
compileOnly project(":libraries:mogo-map")
|
||||
implementation project(':core:mogo-core-res')
|
||||
|
||||
implementation project(":libraries:mogo-speech")
|
||||
implementation 'io.github.youth5201314:banner:2.2.3'
|
||||
|
||||
// implementation project(':OCH:taxi:pcommon')
|
||||
|
||||
if (Boolean.valueOf(USE_MAVEN_PACKAGE)) {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.mogo.och.unmanned.passenger.ui.aiview
|
||||
|
||||
import com.mogo.och.unmanned.passenger.ui.aiview.bean.AIMessage
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
|
||||
|
||||
object AIMessageManager {
|
||||
|
||||
// 使用 CopyOnWriteArrayList 来存储消息回调列表,保证线程安全
|
||||
private val messageListeners: MutableList<AIMessageListener> = CopyOnWriteArrayList()
|
||||
|
||||
|
||||
/**
|
||||
* 注册一个消息监听器。
|
||||
*
|
||||
* @param listener 要注册的 AiMessageListener 实例。
|
||||
*/
|
||||
fun registerListener(listener: AIMessageListener) {
|
||||
messageListeners.add(listener)
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消注册一个消息监听器。
|
||||
*
|
||||
* @param listener 要取消注册的 AiMessageListener 实例。
|
||||
*/
|
||||
fun unregisterListener(listener: AIMessageListener) {
|
||||
messageListeners.remove(listener)
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布一条消息。
|
||||
*
|
||||
* 这条消息会被发送给所有已注册的监听器。
|
||||
*
|
||||
* @param msg 要发布的消息。
|
||||
*/
|
||||
fun post(msg: AIMessage) {
|
||||
// 遍历所有已注册的监听器,并调用它们的 onReceive 方法
|
||||
messageListeners.forEach { callback ->
|
||||
callback.onReceive(msg)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 消息监听器接口。
|
||||
*/
|
||||
interface AIMessageListener {
|
||||
/**
|
||||
* 当接收到消息时,会调用此方法。
|
||||
*
|
||||
* @param msg 接收到的消息。
|
||||
*/
|
||||
fun onReceive(msg: AIMessage)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
package com.mogo.och.unmanned.passenger.ui.aiview
|
||||
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.mogo.eagle.core.data.ai.V2XRepository
|
||||
import com.mogo.eagle.core.data.map.MogoLocation
|
||||
import com.mogo.eagle.core.function.api.autopilot.IMoGoChassisLocationGCJ02Listener
|
||||
import com.mogo.och.unmanned.passenger.ui.aiview.bean.AIMessage
|
||||
import com.mogo.mgintelligent.speech.AsrResult
|
||||
import com.mogo.mgintelligent.speech.AsrState
|
||||
import com.mogo.mgintelligent.speech.IWakeUpListener
|
||||
import com.mogo.mgintelligent.speech.MGSpeech
|
||||
import com.mogo.och.bridge.autopilot.location.OchLocationManager
|
||||
import com.mogo.och.data.taxi.BaseOrderBean
|
||||
import com.mogo.och.data.taxi.TaxiOrderStatusEnum
|
||||
import com.mogo.och.unmanned.taxi.utils.order.OrderListener
|
||||
import com.mogo.och.unmanned.taxi.utils.order.OrderModel
|
||||
import com.mogo.service.v2n.bean.MGLlmQueryBean
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class AIViewModel : ViewModel(), AIMessageManager.AIMessageListener, IWakeUpListener {
|
||||
|
||||
private val msgList = mutableListOf<AIMessage>()
|
||||
private var lastTimestamp = System.currentTimeMillis()
|
||||
|
||||
// 记录最后一次事件发生的时间,使用 private set 限制外部修改
|
||||
// private val TIMESTAMP_THRESHOLD = 1000 * 60 * 5 // 5分钟
|
||||
private val TIMESTAMP_THRESHOLD = 1000 * 30
|
||||
|
||||
private var llmResultJob: Job? = null
|
||||
|
||||
private var isChecking = false
|
||||
|
||||
private val _messagesFlow = MutableStateFlow<List<AIMessage>>(emptyList())
|
||||
val messagesFlow: SharedFlow<List<AIMessage>> get() = _messagesFlow
|
||||
|
||||
private val _asrUIStateFlow = MutableStateFlow<AsrUIState>(AsrUIState.Idle)
|
||||
val asrUIStateFlow: StateFlow<AsrUIState> get() = _asrUIStateFlow
|
||||
|
||||
private val mgSpeech = MGSpeech
|
||||
|
||||
private val tipsTimeOut = "小智还没反应过来,请稍后再试~"
|
||||
private val tipsExit = "我先退下了"
|
||||
val tipsQuestions = listOf(
|
||||
"可以问我:前方路口有拥堵吗?",
|
||||
"可以对我说:介绍一下蘑菇车联?",
|
||||
"想要对话时可以点击:小智智能体",
|
||||
"可以问我:今天天气怎么样?",
|
||||
)
|
||||
private val tipsWakeUpList = listOf(
|
||||
"嗯,我在呢",
|
||||
"在的",
|
||||
"在呢",
|
||||
"你说",
|
||||
)
|
||||
|
||||
private val orderListener = object : OrderListener{
|
||||
override fun onCurrentOrderStatusChanged(order: BaseOrderBean?) {
|
||||
if(order?.orderStatus== TaxiOrderStatusEnum.ArriveAtEnd.code){
|
||||
synchronized(msgList) {
|
||||
msgList.clear()
|
||||
_messagesFlow.value = msgList.toList()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val locationCallback = object : IMoGoChassisLocationGCJ02Listener{
|
||||
override fun onChassisLocationGCJ02(mogoLocation: MogoLocation?) {
|
||||
mogoLocation?.let {
|
||||
V2XRepository.provideLocation(it,0)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
|
||||
}
|
||||
|
||||
fun setViewCallback(aiView: AiViewCallback) {
|
||||
OrderModel.setOrderStatusCallback(TAG,orderListener)
|
||||
OchLocationManager.addGCJ02Listener(TAG,1,locationCallback)
|
||||
AIMessageManager.registerListener(this)
|
||||
mgSpeech.weakUpListener = this
|
||||
mgSpeech.startWeakUp()
|
||||
|
||||
llmResultJob = viewModelScope.launch(Dispatchers.IO) {
|
||||
V2XRepository.llmResultFlow.collect { result ->
|
||||
Log.d(TAG, "llm result: $result")
|
||||
|
||||
val msg = msgList.findLast { it.id == result.queryId }
|
||||
|
||||
if (msg == null || msg !is AIMessage.QA) {
|
||||
Log.w(TAG, "消息不匹配,${msg}")
|
||||
return@collect
|
||||
}
|
||||
|
||||
AIMessageManager.post(
|
||||
AIMessage.QA(
|
||||
id = result.queryId,
|
||||
title = result.answer,
|
||||
tts = result.answer,
|
||||
answer = result.answer,
|
||||
question = msg.question,
|
||||
state = AIMessage.QA.QuestionState.FINISH,
|
||||
pictureUrl = result.imgUrls.getOrNull(0) ?: "",
|
||||
pictureUrlList = result.imgUrls
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override fun onCleared() {
|
||||
AIMessageManager.unregisterListener(this)
|
||||
mgSpeech.weakUpListener = null
|
||||
mgSpeech.stopWeakUp()
|
||||
llmResultJob?.cancel()
|
||||
OrderModel.setOrderStatusCallback(TAG,null)
|
||||
OchLocationManager.removeGCJ02Listener(TAG)
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
override fun onReceive(msg: AIMessage) {
|
||||
Log.d(TAG, "onReceive: $msg")
|
||||
if (isChecking) {
|
||||
if (msg is AIMessage.Event) {
|
||||
msg.showScanFlag = true
|
||||
}
|
||||
}
|
||||
|
||||
// 获更新消息
|
||||
updateMsg(msg)
|
||||
}
|
||||
|
||||
private fun handleMsg(newMessage: AIMessage) {
|
||||
val existingIndex = findMessageIndex(newMessage.id)
|
||||
if (existingIndex != -1) {
|
||||
handleExistingMessage(existingIndex, newMessage)
|
||||
} else {
|
||||
handleNewMessage(newMessage)
|
||||
}
|
||||
}
|
||||
|
||||
private fun findMessageIndex(messageId: String): Int {
|
||||
return msgList.indexOfFirst { it.id == messageId }
|
||||
}
|
||||
|
||||
private fun handleExistingMessage(index: Int, newMessage: AIMessage) {
|
||||
val oldMessage = msgList[index]
|
||||
|
||||
if (shouldSkipUpdate(oldMessage, newMessage)) {
|
||||
return
|
||||
}
|
||||
|
||||
newMessage.showTimestamp = oldMessage.showTimestamp
|
||||
msgList[index] = newMessage
|
||||
|
||||
speakMessageIfNeeded(newMessage, isLastMessage = msgList.last() == newMessage)
|
||||
}
|
||||
|
||||
private fun handleNewMessage(newMessage: AIMessage) {
|
||||
updateTimestampIfNeeded(newMessage)
|
||||
msgList.add(newMessage)
|
||||
speakMessageIfNeeded(newMessage, isLastMessage = true)
|
||||
}
|
||||
|
||||
private fun shouldSkipUpdate(oldMessage: AIMessage, newMessage: AIMessage): Boolean {
|
||||
if (oldMessage is AIMessage.QA
|
||||
&& (oldMessage.state == AIMessage.QA.QuestionState.FINISH ||
|
||||
oldMessage.state == AIMessage.QA.QuestionState.ERROR)
|
||||
) {
|
||||
Log.d(TAG, "handleMsg: 消息拦截")
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun updateTimestampIfNeeded(newMessage: AIMessage) {
|
||||
val time = newMessage.timestamp - lastTimestamp
|
||||
if (time >= TIMESTAMP_THRESHOLD) {
|
||||
newMessage.showTimestamp = true
|
||||
lastTimestamp = newMessage.timestamp
|
||||
}
|
||||
}
|
||||
|
||||
private fun speakMessageIfNeeded(newMessage: AIMessage, isLastMessage: Boolean) {
|
||||
if (isLastMessage && newMessage.tts.isNotEmpty()) {
|
||||
mgSpeech.speak(newMessage.tts)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "AiViewModel"
|
||||
}
|
||||
|
||||
override fun onWakeUp() {
|
||||
mgSpeech.isAssistantShow(true)
|
||||
val random = tipsWakeUpList.random()
|
||||
_asrUIStateFlow.value = AsrUIState.Listening(random)
|
||||
mgSpeech.speak(random, true) {
|
||||
mgSpeech.startAsr()
|
||||
_asrUIStateFlow.value = AsrUIState.Listening("")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onListener(msg: AsrResult) {
|
||||
when (msg.type) {
|
||||
AsrState.STATE_PARTIAL -> {
|
||||
// binding.tvContent.text = msg.content
|
||||
|
||||
_asrUIStateFlow.value = AsrUIState.Listening(msg.content)
|
||||
}
|
||||
|
||||
AsrState.STATE_FINAL -> {
|
||||
// assistantView?.showWaitingResult(msg.content)
|
||||
// binding.tvContent.text = msg.content
|
||||
_asrUIStateFlow.value = AsrUIState.Recognized(msg.content)
|
||||
}
|
||||
|
||||
AsrState.STATE_EXIT -> {
|
||||
_asrUIStateFlow.value = AsrUIState.Idle
|
||||
mgSpeech.speak(tipsExit)
|
||||
mgSpeech.isAssistantShow(false)
|
||||
val content = msg.content
|
||||
if (content.isNotEmpty()) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
// 1.上报消息
|
||||
val msgId = "${System.currentTimeMillis()}"
|
||||
V2XRepository.uploadLlmQuery(MGLlmQueryBean().apply {
|
||||
//user = sn
|
||||
conversationId = ""
|
||||
query = content
|
||||
queryId = msgId
|
||||
})
|
||||
// 2.生成消息
|
||||
val qaMsg = AIMessage.QA(
|
||||
id = msgId,
|
||||
title = "",
|
||||
question = content,
|
||||
state = AIMessage.QA.QuestionState.UNDERSTAND,
|
||||
answer = "",
|
||||
)
|
||||
AIMessageManager.post(qaMsg)
|
||||
// 3.延迟发送状态
|
||||
delay(2000)
|
||||
val analyzeMsg = qaMsg.copy(state = AIMessage.QA.QuestionState.ANALYZE)
|
||||
AIMessageManager.post(analyzeMsg)
|
||||
// 4.延迟发送状态
|
||||
delay(2000)
|
||||
val answerMsg = qaMsg.copy(state = AIMessage.QA.QuestionState.ANSWER)
|
||||
AIMessageManager.post(answerMsg)
|
||||
|
||||
// test
|
||||
delay(10000)
|
||||
val finishMsg = qaMsg.copy(
|
||||
state = AIMessage.QA.QuestionState.ERROR,
|
||||
answer = tipsTimeOut,
|
||||
)
|
||||
AIMessageManager.post(finishMsg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
_asrUIStateFlow.value = AsrUIState.Idle
|
||||
mgSpeech.isAssistantShow(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateMsg(msg: AIMessage) {
|
||||
synchronized(msgList) {
|
||||
handleMsg(msg)
|
||||
_messagesFlow.value = msgList.toList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun deleteMsg(msgId: String) {
|
||||
synchronized(msgList) {
|
||||
val iterator = msgList.iterator()
|
||||
while (iterator.hasNext()) {
|
||||
if (iterator.next().id == msgId) {
|
||||
iterator.remove()
|
||||
}
|
||||
}
|
||||
_messagesFlow.value = msgList.toList()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun getQuestionsRandomIndex(): Int {
|
||||
return tipsQuestions.indexOf(tipsQuestions.random())
|
||||
}
|
||||
|
||||
interface AiViewCallback {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
sealed class AsrUIState {
|
||||
object Idle : AsrUIState()
|
||||
data class Listening(val partialText: String) : AsrUIState()
|
||||
data class Recognized(val finalText: String) : AsrUIState()
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
package com.mogo.och.unmanned.passenger.ui.aiview
|
||||
|
||||
import android.animation.ObjectAnimator
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import android.util.Log
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.widget.TextView
|
||||
import androidx.constraintlayout.motion.widget.MotionLayout
|
||||
import androidx.core.animation.addListener
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.findViewTreeLifecycleOwner
|
||||
import androidx.lifecycle.findViewTreeViewModelStoreOwner
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.mogo.eagle.core.utilcode.kotlin.onClick
|
||||
import com.mogo.eagle.core.utilcode.mogo.logger.CallerLogger
|
||||
import com.mogo.och.common.module.manager.logchainanalytic.OchChainLogManager
|
||||
import com.mogo.och.common.module.utils.BigFrameAnimatorContainer
|
||||
import com.mogo.och.common.module.utils.RxUtils
|
||||
import com.mogo.och.unmanned.passenger.ui.aiview.adapter.AIMessageAdapter
|
||||
import com.mogo.och.unmanned.passenger.ui.aiview.adapter.OnItemClickListener
|
||||
import com.mogo.och.unmanned.passenger.ui.aiview.adapter.PaddingItemDecoration
|
||||
import com.mogo.och.unmanned.passenger.ui.aiview.bean.AIMessage
|
||||
import com.mogo.och.unmanned.taxi.passenger.R
|
||||
import kotlinx.android.synthetic.main.taxt_p_ai.view.aiMotionLayout
|
||||
import kotlinx.android.synthetic.main.taxt_p_ai.view.ivIcon
|
||||
import kotlinx.android.synthetic.main.taxt_p_ai.view.ivListening
|
||||
import kotlinx.android.synthetic.main.taxt_p_ai.view.rvMessages
|
||||
import kotlinx.android.synthetic.main.taxt_p_ai.view.rvMessagesEmpty
|
||||
import kotlinx.android.synthetic.main.taxt_p_ai.view.tvContent
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
|
||||
class AiView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) : MotionLayout(context, attrs, defStyleAttr), AIViewModel.AiViewCallback {
|
||||
|
||||
|
||||
|
||||
private var viewModel:AIViewModel?=null
|
||||
|
||||
private var aiAnimator: BigFrameAnimatorContainer?=null
|
||||
private var aiAnimatorBg: BigFrameAnimatorContainer?=null
|
||||
|
||||
private var tipLooperJob: Job? = null
|
||||
|
||||
@Volatile
|
||||
private var isTipsLooperRunning = false
|
||||
|
||||
private var tipsIndex = 0
|
||||
|
||||
private var isUserScrollingTime = 0L
|
||||
|
||||
|
||||
private val messageAdapter: AIMessageAdapter by lazy { AIMessageAdapter() }
|
||||
private val messageLayoutManager: LinearLayoutManager by lazy {
|
||||
LinearLayoutManager(context).apply {
|
||||
stackFromEnd = true
|
||||
}
|
||||
}
|
||||
|
||||
companion object{
|
||||
private const val TIPS_ROTATION_DELAY = 3000L
|
||||
private const val TAG ="AiView"
|
||||
private const val SCROLL_THRESHOLD = 5000L
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
init {
|
||||
LayoutInflater.from(context).inflate(R.layout.taxt_p_ai, this, true)
|
||||
|
||||
rvMessages.layoutManager = messageLayoutManager
|
||||
rvMessages.adapter = messageAdapter
|
||||
rvMessages.addItemDecoration(PaddingItemDecoration(200, 300))
|
||||
messageAdapter.onItemClickListener = OnItemClickListener { item, position ->
|
||||
if (item is AIMessage.Event) {
|
||||
|
||||
}
|
||||
}
|
||||
rvMessages.addOnScrollListener(object : RecyclerView.OnScrollListener() {
|
||||
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
|
||||
super.onScrollStateChanged(recyclerView, newState)
|
||||
if (newState != RecyclerView.SCROLL_STATE_IDLE) {
|
||||
isUserScrollingTime = System.currentTimeMillis()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if(aiAnimator==null) {
|
||||
aiAnimator = BigFrameAnimatorContainer(R.array.ai_animator, 31, ivIcon)
|
||||
}
|
||||
// aiAnimator?.start()
|
||||
|
||||
ivIcon.onClick {
|
||||
viewModel?.onWakeUp()
|
||||
}
|
||||
|
||||
ivListening.onClick {
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
private fun showListening(show: Boolean) {
|
||||
if (aiAnimatorBg == null) {
|
||||
aiAnimatorBg = BigFrameAnimatorContainer(R.array.ai_animator_listening, 31, ivListening)
|
||||
}
|
||||
|
||||
CallerLogger.d(TAG,"showListening: $show")
|
||||
if (show) {
|
||||
ivListening.alpha = 1F
|
||||
aiAnimatorBg?.start()
|
||||
} else {
|
||||
aiAnimatorBg?.stop()
|
||||
ivListening.alpha = 0F
|
||||
}
|
||||
}
|
||||
|
||||
private fun startTips() {
|
||||
if (isTipsLooperRunning) {
|
||||
CallerLogger.d(TAG,"${tName()} startTips: Already running")
|
||||
return
|
||||
}
|
||||
tipLooperJob?.cancel()
|
||||
|
||||
tipLooperJob = findViewTreeLifecycleOwner()?.lifecycleScope?.launch {
|
||||
isTipsLooperRunning = true
|
||||
CallerLogger.d(TAG,"${tName()} startTipsLooper: start")
|
||||
while (isTipsLooperRunning) {
|
||||
CallerLogger.d(TAG,"${tName()} startTipsLooper: next")
|
||||
viewModel?.let {
|
||||
val tips = it.tipsQuestions
|
||||
val index = tipsIndex++ % tips.size
|
||||
tvContent.changeTextWithFade(tips[index])
|
||||
}
|
||||
delay(TIPS_ROTATION_DELAY)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopTips() {
|
||||
Log.i(TAG, "${tName()} stopTips: stop")
|
||||
tipLooperJob?.cancel()
|
||||
tipLooperJob = null
|
||||
isTipsLooperRunning = false
|
||||
}
|
||||
|
||||
override fun onVisibilityAggregated(isVisible: Boolean) {
|
||||
super.onVisibilityAggregated(isVisible)
|
||||
CallerLogger.d(TAG,"是否展示中:${isVisible}")
|
||||
try {
|
||||
if(isVisible){
|
||||
aiAnimator?.start()
|
||||
RxUtils.createSubscribe(3_000) {
|
||||
aiMotionLayout.setTransitionListener(object :TransitionListener{
|
||||
override fun onTransitionStarted(
|
||||
motionLayout: MotionLayout?,
|
||||
startId: Int,
|
||||
endId: Int
|
||||
) {
|
||||
CallerLogger.d(TAG,"onTransitionStarted:${motionLayout?.id}_$startId $endId")
|
||||
}
|
||||
|
||||
override fun onTransitionChange(
|
||||
motionLayout: MotionLayout?,
|
||||
startId: Int,
|
||||
endId: Int,
|
||||
progress: Float
|
||||
) {
|
||||
CallerLogger.d(TAG,"onTransitionChange:${motionLayout?.id}_$startId $endId ————${progress}")
|
||||
}
|
||||
|
||||
override fun onTransitionCompleted(
|
||||
motionLayout: MotionLayout?,
|
||||
currentId: Int
|
||||
) {
|
||||
CallerLogger.d(TAG,"${tName()} onTransitionCompleted:${motionLayout?.id}_$currentId")
|
||||
rvMessagesEmpty.visibility = View.VISIBLE
|
||||
startContextInfo()
|
||||
startListInfo()
|
||||
}
|
||||
|
||||
override fun onTransitionTrigger(
|
||||
motionLayout: MotionLayout?,
|
||||
triggerId: Int,
|
||||
positive: Boolean,
|
||||
progress: Float
|
||||
) {
|
||||
CallerLogger.d(TAG,"onTransitionTrigger:${motionLayout?.id}_$triggerId ${triggerId} $positive $progress")
|
||||
}
|
||||
|
||||
})
|
||||
aiMotionLayout.transitionToEnd()
|
||||
}
|
||||
}else{
|
||||
aiAnimator?.stop()
|
||||
}
|
||||
}catch (e:Exception){
|
||||
OchChainLogManager.writeChainLog("展示崩溃","${e.message}")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
override fun onAttachedToWindow() {
|
||||
super.onAttachedToWindow()
|
||||
viewModel = findViewTreeViewModelStoreOwner()?.let {
|
||||
ViewModelProvider(it).get(AIViewModel::class.java)
|
||||
}
|
||||
viewModel?.setViewCallback(this)
|
||||
}
|
||||
|
||||
private fun startListInfo(){
|
||||
tipsIndex = viewModel?.getQuestionsRandomIndex()?:0
|
||||
findViewTreeLifecycleOwner()?.lifecycleScope?.launch {
|
||||
viewModel?.messagesFlow?.collect {
|
||||
Log.d(TAG, "${tName()} onMessages update: ${it}")
|
||||
if(it.isNotEmpty()){
|
||||
rvMessagesEmpty.visibility = View.INVISIBLE
|
||||
}else{
|
||||
rvMessagesEmpty.visibility = View.VISIBLE
|
||||
}
|
||||
messageAdapter.submitList(it) {
|
||||
Log.d(TAG, "${tName()} adapter submit: ")
|
||||
scrollToBottom()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startContextInfo(){
|
||||
findViewTreeLifecycleOwner()?.lifecycleScope?.launch {
|
||||
viewModel?.asrUIStateFlow?.collect {
|
||||
Log.d(TAG, "${tName()}asr ui state $it")
|
||||
|
||||
when (it) {
|
||||
is AsrUIState.Idle -> {
|
||||
startTips()
|
||||
showListening(false)
|
||||
}
|
||||
|
||||
is AsrUIState.Listening -> {
|
||||
stopTips()
|
||||
showListening(true)
|
||||
tvContent.text = it.partialText
|
||||
}
|
||||
|
||||
is AsrUIState.Recognized -> {
|
||||
stopTips()
|
||||
tvContent.text = it.finalText
|
||||
showListening(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
aiAnimator?.stop()
|
||||
aiAnimator = null
|
||||
aiAnimatorBg?.stop()
|
||||
aiAnimatorBg = null
|
||||
tipLooperJob?.cancel()
|
||||
super.onDetachedFromWindow()
|
||||
}
|
||||
|
||||
// 滚动到RecyclerView底部
|
||||
private fun scrollToBottom() {
|
||||
val delay = System.currentTimeMillis() - isUserScrollingTime
|
||||
if (delay < SCROLL_THRESHOLD) {
|
||||
return
|
||||
}
|
||||
val layoutManager = rvMessages.layoutManager as LinearLayoutManager
|
||||
layoutManager.scrollToPositionWithOffset(messageAdapter.itemCount - 1, 0)
|
||||
}
|
||||
|
||||
fun tName(): String {
|
||||
return "【${Thread.currentThread().name}】"
|
||||
}
|
||||
|
||||
private suspend fun TextView.changeTextWithFade(newText: String) {
|
||||
fadeOutFlow()
|
||||
text = newText
|
||||
fadeInFlow()
|
||||
}
|
||||
|
||||
private suspend fun View.fadeOutFlow(duration: Long = 100) = suspendCancellableCoroutine<Unit> { continuation ->
|
||||
val animator = ObjectAnimator.ofFloat(this@fadeOutFlow, "alpha", 1f, 0f).apply {
|
||||
this.duration = duration
|
||||
}
|
||||
animator.addListener(onEnd = {
|
||||
continuation.resume(Unit) {
|
||||
animator.cancel()
|
||||
}
|
||||
})
|
||||
animator.start()
|
||||
continuation.invokeOnCancellation {
|
||||
Log.i(TAG, "fadeOutFlow: cancel")
|
||||
animator.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun View.fadeInFlow(duration: Long = 100) =
|
||||
suspendCancellableCoroutine<Unit> { continuation ->
|
||||
val animator = ObjectAnimator.ofFloat(this@fadeInFlow, "alpha", 0f, 1f).apply {
|
||||
this.duration = duration
|
||||
}
|
||||
animator.addListener(onEnd = {
|
||||
continuation.resume(Unit) {
|
||||
animator.cancel()
|
||||
}
|
||||
})
|
||||
animator.start()
|
||||
continuation.invokeOnCancellation {
|
||||
CallerLogger.d(TAG,"fadeInFlow: cancel")
|
||||
animator.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.mogo.och.unmanned.passenger.ui.aiview.adapter
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.ListAdapter
|
||||
import com.mogo.och.unmanned.passenger.ui.aiview.bean.AIMessage
|
||||
import com.mogo.och.unmanned.taxi.passenger.R
|
||||
|
||||
class AIMessageAdapter : ListAdapter<AIMessage, MessageViewHolder>(MessageDiffCallback()) {
|
||||
|
||||
var onItemClickListener: OnItemClickListener? = null
|
||||
|
||||
override fun onBindViewHolder(holder: MessageViewHolder, position: Int) {
|
||||
getItem(position)?.let {
|
||||
holder.bind(it, onItemClickListener)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MessageViewHolder {
|
||||
val inflater = LayoutInflater.from(parent.context)
|
||||
return when (viewType) {
|
||||
AIMessage.TYPE_QA -> QAViewHolder(inflater.inflate(R.layout.item_ai_msg_qa, parent, false))
|
||||
else -> throw IllegalArgumentException("Invalid view type")
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemViewType(position: Int): Int {
|
||||
return when (getItem(position)) {
|
||||
is AIMessage.Event -> AIMessage.TYPE_EVENT
|
||||
is AIMessage.QA -> AIMessage.TYPE_QA
|
||||
is AIMessage.Scan -> AIMessage.TYPE_SCAN
|
||||
is AIMessage.Light -> AIMessage.TYPE_LIGHT
|
||||
is AIMessage.Speed -> AIMessage.TYPE_SPEED
|
||||
is AIMessage.Warning -> AIMessage.TYPE_WARNING
|
||||
else -> AIMessage.TYPE_EVENT
|
||||
}
|
||||
}
|
||||
|
||||
override fun onViewRecycled(holder: MessageViewHolder) {
|
||||
super.onViewRecycled(holder)
|
||||
holder.viewRecycled(holder)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package com.mogo.och.unmanned.passenger.ui.aiview.adapter
|
||||
|
||||
import android.animation.ObjectAnimator
|
||||
import android.util.Log
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.core.graphics.toColorInt
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.bumptech.glide.Glide
|
||||
import com.mogo.och.common.module.wigets.OchRoundImageView
|
||||
import com.mogo.och.unmanned.passenger.ui.aiview.bean.AIMessage
|
||||
import com.mogo.och.unmanned.taxi.passenger.R
|
||||
import com.youth.banner.Banner
|
||||
import com.youth.banner.indicator.CircleIndicator
|
||||
import com.youth.banner.transformer.ScaleInTransformer
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
|
||||
abstract class MessageViewHolder(view: View) : RecyclerView.ViewHolder(view) {
|
||||
abstract fun bind(item: AIMessage, onItemClickListener: OnItemClickListener? = null)
|
||||
open fun viewRecycled(holder: MessageViewHolder){}
|
||||
private val sampleDateFormat = SimpleDateFormat("HH:mm", Locale.CHINA)
|
||||
protected val TAG = javaClass.simpleName
|
||||
|
||||
fun handleTimestamp(item: AIMessage, tvTimestamp: TextView) {
|
||||
if (item.showTimestamp) {
|
||||
tvTimestamp.visibility = View.VISIBLE
|
||||
tvTimestamp.text = sampleDateFormat.format(Date(item.timestamp))
|
||||
} else {
|
||||
tvTimestamp.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
fun View.setVisibilityBasedOn(condition: Boolean) {
|
||||
visibility = if (condition) View.VISIBLE else View.GONE
|
||||
}
|
||||
|
||||
fun TextView.setTextAndVisibility(text: String) {
|
||||
if (text.isEmpty()) {
|
||||
visibility = View.GONE
|
||||
this.text = ""
|
||||
} else {
|
||||
visibility = View.VISIBLE
|
||||
this.text = text
|
||||
}
|
||||
}
|
||||
|
||||
fun ImageView.showOrHideWithUrl(url: String) {
|
||||
if (url.isEmpty()) {
|
||||
visibility = View.GONE
|
||||
|
||||
} else {
|
||||
visibility = View.VISIBLE
|
||||
Glide.with(this)
|
||||
.load(url)
|
||||
.placeholder(R.drawable.icon_pic_holder)
|
||||
.error(R.drawable.icon_pic_error)
|
||||
// .error(R.drawable.icon_marker_window_place_holder)
|
||||
// .placeholder(R.drawable.icon_marker_window_place_holder)
|
||||
.into(this)
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class QAViewHolder(val binding: View) : MessageViewHolder(binding) {
|
||||
private var tvQuestion: TextView = binding.findViewById(R.id.tvQuestion)
|
||||
private var tvAnswer: TextView = binding.findViewById(R.id.tvAnswer)
|
||||
private var tvTimestamp: TextView = binding.findViewById(R.id.tvTimestamp)
|
||||
private var tvStateContent: TextView = binding.findViewById(R.id.tvStateContent)
|
||||
// private var ivPicture: OchRoundImageView = binding.findViewById(R.id.ivPicture)
|
||||
private var llState: LinearLayout = binding.findViewById(R.id.llState)
|
||||
private var tvStateUnderstand: TextView = binding.findViewById(R.id.tvStateUnderstand)
|
||||
private var tvStateAnalyze: TextView = binding.findViewById(R.id.tvStateAnalyze)
|
||||
private var tvStateAnswer: TextView = binding.findViewById(R.id.tvStateAnswer)
|
||||
private var picBanner = binding.findViewById<Banner<String,BannerImageAdapter>>(R.id.picBanner)
|
||||
|
||||
|
||||
override fun bind(item: AIMessage, onItemClickListener: OnItemClickListener?) {
|
||||
if (item is AIMessage.QA) {
|
||||
binding.apply {
|
||||
handleState(item.state)
|
||||
tvQuestion.text = item.question
|
||||
Log.d(TAG, "bind: ${item}")
|
||||
|
||||
tvAnswer.setTextAndVisibility(item.answer)
|
||||
// ivPicture.showOrHideWithUrl(item.pictureUrl)
|
||||
|
||||
handleTimestamp(item, tvTimestamp)
|
||||
|
||||
val pictureUrlList = item.pictureUrlList
|
||||
if (pictureUrlList.isEmpty()) {
|
||||
picBanner.visibility = View.GONE
|
||||
} else {
|
||||
picBanner.visibility = View.VISIBLE
|
||||
picBanner.setAdapter(BannerImageAdapter(pictureUrlList))
|
||||
// .addBannerLifecycleObserver(picBanner.context) //添加生命周期观察者
|
||||
.setIndicator(CircleIndicator(picBanner.context))
|
||||
.setIndicatorSelectedColor("#40B4FF".toColorInt())
|
||||
.setIndicatorNormalColor("#FFFFFF".toColorInt())
|
||||
.setPageTransformer(ScaleInTransformer())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleState(state: AIMessage.QA.QuestionState) {
|
||||
when (state) {
|
||||
AIMessage.QA.QuestionState.UNDERSTAND -> {
|
||||
llState.visibility = View.VISIBLE
|
||||
tvStateContent.text = "正在理解问题…"
|
||||
tvStateUnderstand.visibility = View.GONE
|
||||
tvStateAnalyze.visibility = View.GONE
|
||||
tvStateAnswer.visibility = View.GONE
|
||||
}
|
||||
|
||||
AIMessage.QA.QuestionState.ANALYZE -> {
|
||||
tvStateUnderstand.visibility = View.VISIBLE
|
||||
llState.visibility = View.VISIBLE
|
||||
tvStateContent.text = "正在分析处理…"
|
||||
tvStateAnalyze.visibility = View.GONE
|
||||
tvStateAnswer.visibility = View.GONE
|
||||
}
|
||||
|
||||
AIMessage.QA.QuestionState.ANSWER -> {
|
||||
tvStateUnderstand.visibility = View.VISIBLE
|
||||
tvStateAnalyze.visibility = View.VISIBLE
|
||||
llState.visibility = View.VISIBLE
|
||||
tvStateContent.text = "正在总结回答…"
|
||||
tvStateAnswer.visibility = View.GONE
|
||||
}
|
||||
|
||||
|
||||
AIMessage.QA.QuestionState.FINISH -> {
|
||||
tvStateUnderstand.visibility = View.VISIBLE
|
||||
tvStateAnalyze.visibility = View.VISIBLE
|
||||
tvStateAnswer.visibility = View.VISIBLE
|
||||
|
||||
llState.visibility = View.GONE
|
||||
tvStateContent.text = ""
|
||||
}
|
||||
|
||||
AIMessage.QA.QuestionState.ERROR -> {
|
||||
tvStateUnderstand.visibility = View.VISIBLE
|
||||
tvStateAnalyze.visibility = View.VISIBLE
|
||||
tvStateAnswer.visibility = View.VISIBLE
|
||||
|
||||
llState.visibility = View.GONE
|
||||
tvStateContent.text = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
fun interface OnItemClickListener {
|
||||
fun onItemClick(item: AIMessage, position: Int)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.mogo.och.unmanned.passenger.ui.aiview.adapter
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.bumptech.glide.Glide
|
||||
import com.mogo.och.unmanned.taxi.passenger.R
|
||||
import com.youth.banner.adapter.BannerAdapter
|
||||
|
||||
open class BannerImageAdapter(imgList: List<String>) :
|
||||
BannerAdapter<String, BannerHolder>(imgList) {
|
||||
override fun onBindView(holder: BannerHolder?, data: String?, position: Int, size: Int) {
|
||||
//图片加载自己实现
|
||||
holder ?: return
|
||||
val imageView = holder.view
|
||||
Glide.with(imageView)
|
||||
.load(data)
|
||||
// .apply(RequestOptions.bitmapTransform(RoundedCorners(30)))
|
||||
.into(imageView)
|
||||
}
|
||||
|
||||
override fun onCreateHolder(parent: ViewGroup, viewType: Int): BannerHolder {
|
||||
val inflater = LayoutInflater.from(parent.context)
|
||||
val view = inflater.inflate(R.layout.item_ai_banner_item, parent, false) as ImageView
|
||||
return BannerHolder(view)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class BannerHolder(val view: ImageView) : RecyclerView.ViewHolder(view)
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.mogo.och.unmanned.passenger.ui.aiview.adapter
|
||||
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import com.mogo.och.unmanned.passenger.ui.aiview.bean.AIMessage
|
||||
|
||||
class MessageDiffCallback: DiffUtil.ItemCallback<AIMessage>() {
|
||||
|
||||
override fun areContentsTheSame(oldItem: AIMessage, newItem: AIMessage): Boolean {
|
||||
return oldItem == newItem
|
||||
}
|
||||
|
||||
override fun areItemsTheSame(oldItem: AIMessage, newItem: AIMessage): Boolean {
|
||||
return oldItem.id == newItem.id
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.mogo.och.unmanned.passenger.ui.aiview.adapter
|
||||
|
||||
import android.graphics.Rect
|
||||
import android.view.View
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
|
||||
class PaddingItemDecoration(private val topPadding: Int, private val bottomPadding: Int) : RecyclerView.ItemDecoration() {
|
||||
override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
|
||||
super.getItemOffsets(outRect, view, parent, state)
|
||||
|
||||
|
||||
// 只有第一个 item 的顶部添加空白
|
||||
if (parent.getChildAdapterPosition(view) == 0) {
|
||||
outRect.top = topPadding
|
||||
} else{
|
||||
outRect.top = 0
|
||||
}
|
||||
|
||||
// 最后一个 item 的底部添加空白
|
||||
// if (parent.getChildAdapterPosition(view) == state.itemCount - 1) {
|
||||
// outRect.bottom = bottomPadding
|
||||
// } else{
|
||||
// outRect.bottom = 0
|
||||
// }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package com.mogo.och.unmanned.passenger.ui.aiview.bean
|
||||
|
||||
import android.os.CountDownTimer
|
||||
import android.util.Log
|
||||
import kotlin.math.floor
|
||||
|
||||
|
||||
sealed class AIMessage(
|
||||
open val id: String,
|
||||
open val title: String,
|
||||
open val tts: String = "",
|
||||
val timestamp: Long = System.currentTimeMillis(),
|
||||
var showTimestamp: Boolean = false
|
||||
) {
|
||||
|
||||
companion object {
|
||||
const val TYPE_SCAN = 0
|
||||
const val TYPE_EVENT = 1
|
||||
const val TYPE_QA = 2
|
||||
const val TYPE_LIGHT = 3
|
||||
const val TYPE_SPEED = 4
|
||||
const val TYPE_WARNING = 5
|
||||
}
|
||||
|
||||
data class Scan(
|
||||
override val id: String,
|
||||
override val title: String,
|
||||
val pictureUrl: String = "",
|
||||
var showScanFlag: Boolean = false
|
||||
) : AIMessage(id, title)
|
||||
|
||||
data class Event(
|
||||
override val id: String,
|
||||
override val title: String,
|
||||
override val tts: String = "",
|
||||
val position: String = "",
|
||||
val distance: String = "",
|
||||
val range: String = "",
|
||||
val time: String = "",
|
||||
val pictureUrl: String = "",
|
||||
val videoUrl: String = "",
|
||||
var showScanFlag: Boolean = false
|
||||
) : AIMessage(id, title, tts)
|
||||
|
||||
data class QA(
|
||||
override val id: String,
|
||||
override val title: String,
|
||||
override val tts: String = "",
|
||||
val question: String,
|
||||
val answer: String,
|
||||
var state: QuestionState = QuestionState.UNDERSTAND,
|
||||
val pictureUrl: String = "",
|
||||
var pictureUrlList: List<String> = listOf(),
|
||||
val videoUrl: String = "",
|
||||
) : AIMessage(id, title, tts) {
|
||||
|
||||
enum class QuestionState(val code: Int) {
|
||||
UNDERSTAND(1),
|
||||
ANALYZE(2),
|
||||
ANSWER(3),
|
||||
FINISH(4),
|
||||
ERROR(-1),
|
||||
}
|
||||
}
|
||||
|
||||
data class Light(
|
||||
override val id: String,
|
||||
override val title: String,
|
||||
override val tts: String = "",
|
||||
var seconds: Int,
|
||||
val status: Int
|
||||
) : AIMessage(id, title, tts) {
|
||||
private var countDownTimer: CountDownTimer? = null
|
||||
private var listener: OnCountdownUpdateListener? = null
|
||||
|
||||
fun startCountdown(millisInFuture: Long, countDownInternal: Long) {
|
||||
countDownTimer?.cancel()
|
||||
countDownTimer = object : CountDownTimer(millisInFuture, countDownInternal) {
|
||||
override fun onTick(millisUntilFinished: Long) {
|
||||
//倒计时开始
|
||||
Log.d(
|
||||
"StartOrSlowDownTip",
|
||||
"millisUntilFinished = $millisUntilFinished, countDownInternal = $countDownInternal"
|
||||
)
|
||||
val cd = millisUntilFinished / 1000.0
|
||||
// val split = String.format("%.2f", cd).split(".")
|
||||
seconds = floor(cd).toInt()
|
||||
listener?.onCountdownUpdate()
|
||||
}
|
||||
|
||||
override fun onFinish() {
|
||||
//倒计时完成
|
||||
seconds = 0
|
||||
listener?.onCountdownFinish()
|
||||
}
|
||||
}
|
||||
countDownTimer?.start()
|
||||
}
|
||||
|
||||
fun stopCountdown() {
|
||||
countDownTimer?.cancel()
|
||||
}
|
||||
|
||||
fun setOnCountdownUpdateListener(listener: OnCountdownUpdateListener) {
|
||||
this.listener = listener
|
||||
}
|
||||
|
||||
interface OnCountdownUpdateListener {
|
||||
fun onCountdownUpdate()
|
||||
fun onCountdownFinish()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
data class Speed(
|
||||
override val id: String,
|
||||
override val title: String,
|
||||
override val tts: String = "",
|
||||
val speedMax: Int,
|
||||
val speedMin: Int,
|
||||
) : AIMessage(id, title, tts)
|
||||
|
||||
data class Warning(
|
||||
override val id: String,
|
||||
override val title: String,
|
||||
override val tts: String = "",
|
||||
) : AIMessage(id, title, tts)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package com.mogo.och.unmanned.passenger.ui.aiview.bean
|
||||
|
||||
data class ListenUIState(val show: Boolean, val text: String, val showTips: Boolean = false)
|
||||
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |