[fea]
[ai+高精地图]
This commit is contained in:
yangyakun
2025-04-22 18:24:32 +08:00
parent 45bf1ebb33
commit 5cb104a709
217 changed files with 3554 additions and 66 deletions

View File

@@ -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()
}
}

View File

@@ -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>

View File

@@ -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)) {

View File

@@ -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)
}
}

View File

@@ -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()
}

View File

@@ -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()
}
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}

View File

@@ -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)

View File

@@ -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
}
}

View File

@@ -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
// }
}
}

View File

@@ -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)
}

View File

@@ -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)

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Some files were not shown because too many files have changed in this diff Show More