瀏覽代碼

feat: 录音并发送

DoggyZhang 4 月之前
父節點
當前提交
461b56af51

+ 4 - 1
app/src/main/java/com/adealink/weparty/module/im/data/Tags.kt

@@ -1,4 +1,7 @@
 package com.adealink.weparty.module.im.data
 
 const val TAG_IM = "tag_im"
-const val TAG_IM_SESSION ="${TAG_IM}_session"
+const val TAG_IM_SESSION ="${TAG_IM}_session"
+
+const val TAG_IM_INPUT_FLOW ="${TAG_IM}_input_flow"
+const val TAG_IM_AUDIO ="${TAG_IM}_audio"

+ 9 - 1
module/im/src/main/java/com/adealink/weparty/im/session/SessionFragment.kt

@@ -1,8 +1,8 @@
 package com.adealink.weparty.im.session
 
 import androidx.core.view.WindowInsetsCompat
-import androidx.core.view.doOnPreDraw
 import androidx.core.view.updatePadding
+import androidx.fragment.app.activityViewModels
 import com.adealink.frame.base.fastLazy
 import com.adealink.frame.log.Log
 import com.adealink.frame.mvvm.view.viewBinding
@@ -15,6 +15,7 @@ import com.adealink.weparty.im.R
 import com.adealink.weparty.im.databinding.FragmentSessionBinding
 import com.adealink.weparty.im.session.adapter.SessionAdapter
 import com.adealink.weparty.im.session.comp.SessionBottomComp
+import com.adealink.weparty.im.session.comp.viewmodel.SessionInputViewModel
 import com.adealink.weparty.module.im.data.TAG_IM_SESSION
 import com.tencent.qcloud.tuikit.tuichat.TUIChatConstants
 import com.tencent.qcloud.tuikit.tuichat.bean.C2CChatInfo
@@ -32,6 +33,8 @@ class SessionFragment : BaseFragment(R.layout.fragment_session) {
 
     private val binding by viewBinding(FragmentSessionBinding::bind)
 
+    private val inputViewModel by activityViewModels<SessionInputViewModel>()
+
     private val sessionAdapter: SessionAdapter by fastLazy { SessionAdapter() }
     private var sessionPresenter: ChatPresenter? = null
 
@@ -90,6 +93,11 @@ class SessionFragment : BaseFragment(R.layout.fragment_session) {
         }
     }
 
+    override fun observeViewModel() {
+        super.observeViewModel()
+        inputViewModel.init(chatInfo, sessionPresenter)
+    }
+
     override fun loadData() {
         super.loadData()
         sessionPresenter?.loadMessage(

+ 277 - 0
module/im/src/main/java/com/adealink/weparty/im/session/comp/SessionBottomAudioComp.kt

@@ -0,0 +1,277 @@
+package com.adealink.weparty.im.session.comp
+
+import android.annotation.SuppressLint
+import androidx.lifecycle.LifecycleOwner
+import com.adealink.frame.log.Log
+import com.adealink.frame.mvvm.view.ViewComponent
+import com.adealink.frame.mvvm.viewmodel.activityViewModels
+import com.adealink.frame.util.onClick
+import com.adealink.frame.util.runOnUiThread
+import com.adealink.weparty.commonui.toast.util.showToast
+import com.adealink.weparty.im.R
+import com.adealink.weparty.im.databinding.LayoutSessionBottomVoiceBarBinding
+import com.adealink.weparty.im.session.comp.input.InputAction
+import com.adealink.weparty.im.session.comp.input.InputMachineTransaction
+import com.adealink.weparty.im.session.comp.input.InputState
+import com.adealink.weparty.im.session.comp.viewmodel.SessionInputViewModel
+import com.adealink.weparty.module.im.data.TAG_IM_AUDIO
+import com.tencent.qcloud.tuicore.TUIConstants
+import com.tencent.qcloud.tuicore.util.ToastUtil
+import com.tencent.qcloud.tuikit.tuichat.TUIChatService
+import com.tencent.qcloud.tuikit.tuichat.bean.ChatInfo
+import com.tencent.qcloud.tuikit.tuichat.component.audio.AudioRecorder
+import com.tencent.qcloud.tuikit.tuichat.component.audio.AudioRecorder.AudioRecorderCallback
+import com.tencent.qcloud.tuikit.tuichat.presenter.ChatPresenter
+import com.tencent.qcloud.tuikit.tuichat.util.ChatMessageBuilder
+import java.util.Timer
+import java.util.TimerTask
+
+@SuppressLint("SetTextI18n")
+class SessionBottomAudioComp(
+    lifecycleOwner: LifecycleOwner,
+    val voiceBar: LayoutSessionBottomVoiceBarBinding,
+    val chatInfo: ChatInfo? = null,
+    val presenter: ChatPresenter? = null
+) : ViewComponent(lifecycleOwner) {
+
+    private var mTimer: Timer? = null
+    private var times = 0
+    private val inputViewModel by activityViewModels<SessionInputViewModel>()
+
+    private var isPause = false
+    override fun onCreate() {
+        super.onCreate()
+        initView()
+        observeViewModel()
+    }
+
+
+    private fun initView() {
+        voiceBar.btnRemove.onClick {
+            clickDelete()
+        }
+        voiceBar.btnPause.onClick {
+            clickPause()
+        }
+        voiceBar.btnSend.onClick {
+            clickSend()
+        }
+    }
+
+    private fun observeViewModel() {
+        /**
+         * transition to [InputState.STATE_AUDIO_INPUT]
+         */
+        inputViewModel.registerTransaction(
+            InputMachineTransaction(
+                currentState = InputState.STATE_SOFT_INPUT,
+                action = InputAction.CLICK_AUDIO,
+                nextState = InputState.STATE_AUDIO_INPUT
+            ).also {
+                it.event.observeWithoutCache(viewLifecycleOwner) {
+                    startAudioRecord()
+                }
+            }
+        )
+        inputViewModel.registerTransaction(
+            InputMachineTransaction(
+                currentState = InputState.STATE_EMOJI_INPUT,
+                action = InputAction.CLICK_AUDIO,
+                nextState = InputState.STATE_AUDIO_INPUT
+            ).also {
+                it.event.observeWithoutCache(viewLifecycleOwner) {
+                    startAudioRecord()
+                }
+            }
+        )
+        inputViewModel.registerTransaction(
+            InputMachineTransaction(
+                currentState = InputState.STATE_NONE,
+                action = InputAction.CLICK_AUDIO,
+                nextState = InputState.STATE_AUDIO_INPUT
+            ).also {
+                it.event.observeWithoutCache(viewLifecycleOwner) {
+                    startAudioRecord()
+                }
+            }
+        )
+        /**
+         * transition from [InputState.STATE_AUDIO_INPUT]
+         */
+        inputViewModel.inputStateLD.observe(viewLifecycleOwner) { state ->
+            if (state.lastState == InputState.STATE_AUDIO_INPUT
+                && state.currentState != InputState.STATE_AUDIO_INPUT
+            ) {
+                stopAudioRecord()
+            }
+        }
+    }
+
+    private fun clickDelete() {
+        inputViewModel.execute(InputAction.CANCEL_AUDIO)
+    }
+
+    private fun clickPause() {
+        isPause = !isPause
+        if (isPause) {
+            voiceBar.btnPause.setImageResource(R.drawable.im_session_voice_record_resume_ic)
+        } else {
+            voiceBar.btnPause.setImageResource(R.drawable.im_session_voice_record_pause_ic)
+        }
+    }
+
+    private fun clickSend() {
+        inputViewModel.execute(InputAction.CANCEL_AUDIO)
+    }
+
+    private fun startAudioRecord() {
+        Log.d(TAG_IM_AUDIO, "startAudioRecord")
+        AudioRecorder.startRecord(object : AudioRecorderCallback {
+            override fun onStarted() {
+                Log.d(TAG_IM_AUDIO, "startAudioRecord, onStarted")
+                showVoiceLayout()
+                if (mTimer == null) {
+                    mTimer = Timer()
+                }
+                mTimer?.schedule(object : TimerTask() {
+                    override fun run() {
+                        runOnUiThread {
+                            times++
+                            voiceBar.tvRecordTime.text = formatMiss(times)
+                        }
+                    }
+                }, 0, 1000)
+            }
+
+            override fun onFinished(outputPath: String?) {
+                Log.d(TAG_IM_AUDIO, "startAudioRecord, onFinished:$outputPath")
+                val duration = AudioRecorder.getDuration(outputPath)
+                if (duration < 1000) {
+                    showToast(R.string.im_audio_say_time_short)
+                    return
+                }
+//                if (mVoiceWaveView != null) {
+//                    mVoiceWaveView.stop()
+//                }
+                sendAudioMessage(outputPath, duration)
+            }
+
+            override fun onFailed(errorCode: Int, errorMessage: String?) {
+                Log.d(
+                    TAG_IM_AUDIO,
+                    "startAudioRecord, onFailed, errorCode:$errorCode, errorMessage:$errorMessage"
+                )
+                if (errorCode == AudioRecorder.ERROR_CODE_MIC_IS_BEING_USED || errorCode == TUIConstants.TUICalling.ERROR_STATUS_IN_CALL) {
+                    ToastUtil.toastLongMessage(
+                        TUIChatService.getAppContext()
+                            .getString(R.string.im_mic_is_being_used_cant_record)
+                    )
+                } else {
+                    ToastUtil.toastLongMessage(
+                        TUIChatService.getAppContext().getString(R.string.im_record_audio_failed)
+                    )
+                }
+                hideVoiceLayout()
+                Log.e(
+                    TAG_IM_AUDIO,
+                    "record audio failed, errorCode $errorCode, errorMessage $errorMessage"
+                )
+//                if (mVoiceWaveView != null) {
+//                    mVoiceWaveView.stop()
+//                }
+            }
+
+            override fun onCanceled() {
+                Log.d(TAG_IM_AUDIO, "startAudioRecord, onCanceled")
+//                if (mChatInputHandler != null) {
+//                    mChatInputHandler.onRecordStatusChanged(InputView.ChatInputHandler.RECORD_CANCEL)
+//                }
+//                if (mVoiceWaveView != null) {
+//                    mVoiceWaveView.stop()
+//                }
+                hideVoiceLayout()
+            }
+
+            override fun onVoiceDb(db: Double) {
+                //Log.d(TAG_IM_AUDIO, "startAudioRecord, onVoiceDb: $db")
+                //var db = db
+                // TODO: 声音分贝
+//                if (mSendAudioButtonLayout.getVisibility() == View.VISIBLE) {
+//                    if (db == 0.0) {
+//                        db = 2.0
+//                    }
+//                    mVoiceWaveView.addBody(db.toInt())
+//                    mVoiceWaveView.start()
+//                }
+            }
+        })
+    }
+
+    private fun hideVoiceLayout() {
+        Log.d(TAG_IM_AUDIO, "hideVoiceLayout")
+        resetVoiceView()
+//        voiceBtn.setVisibility(View.VISIBLE)
+//        mSendAudioButtonLayout.setVisibility(View.GONE)
+//        showInputMoreButton()
+//        showTextInputLayout()
+//        showImageButton()
+//        hideVoiceDeleteImage()
+    }
+
+    private fun showVoiceLayout() {
+        Log.d(TAG_IM_AUDIO, "showVoiceLayout")
+//        initVoiceWaveView()
+//        mSendAudioButtonLayout.setVisibility(View.VISIBLE)
+//        voiceBtn.setVisibility(View.GONE)
+//        hideInputMoreButton()
+//        hideTextInputLayout()
+//        hideImageButton()
+//        showVoiceDeleteImage()
+    }
+
+    private fun stopAudioRecord() {
+        Log.d(TAG_IM_AUDIO, "stopAudioRecord")
+        AudioRecorder.stopRecord()
+    }
+
+    private fun resetVoiceView() {
+        Log.d(TAG_IM_AUDIO, "resetVoiceView")
+//        initVoiceWaveView()
+        mTimer?.cancel()
+        mTimer = null
+        voiceBar.tvRecordTime.text = "0:00"
+        times = 0
+    }
+
+    private fun sendAudioMessage(outputPath: String?, duration: Int) {
+        Log.d(TAG_IM_AUDIO, "sendAudioMessage, outputPath:$outputPath, duration:$duration")
+        inputViewModel.sendMessage(ChatMessageBuilder.buildAudioMessage(outputPath, duration))
+    }
+
+    // TODO: 手指操作
+//    private fun readyToCancelRecord() {
+//        if (mChatInputHandler != null) {
+//            mChatInputHandler.onRecordStatusChanged(InputView.ChatInputHandler.RECORD_READY_TO_CANCEL)
+//        }
+//        voiceDeleteImage.setBackgroundResource(R.drawable.minimalist_delete_start_icon)
+//        voiceDeleteImage.getBackground().setAutoMirrored(true)
+//        mSendAudioButton.setBackground(getResources().getDrawable(R.drawable.minimalist_corner_bg_red))
+//    }
+//
+//    private fun continueRecord() {
+//        if (mChatInputHandler != null) {
+//            mChatInputHandler.onRecordStatusChanged(InputView.ChatInputHandler.RECORD_CONTINUE)
+//        }
+//        voiceDeleteImage.setBackgroundResource(R.drawable.minimalist_delete_icon)
+//        mSendAudioButton.setBackground(getResources().getDrawable(R.drawable.minimalist_corner_bg_blue))
+//    }
+
+    private fun formatMiss(miss: Int): String {
+        // String hh = miss / 3600 > 9 ? miss / 3600 + "" : "0" + miss / 3600;
+        val mm =
+            if ((miss % 3600) / 60 > 9) ((miss % 3600) / 60).toString() + "" else "0" + (miss % 3600) / 60
+        val ss =
+            if ((miss % 3600) % 60 > 9) ((miss % 3600) % 60).toString() + "" else "0" + (miss % 3600) % 60
+        return "$mm:$ss"
+    }
+}

+ 24 - 16
module/im/src/main/java/com/adealink/weparty/im/session/comp/SessionBottomComp.kt

@@ -2,23 +2,25 @@ package com.adealink.weparty.im.session.comp
 
 import androidx.lifecycle.LifecycleOwner
 import com.adealink.frame.mvvm.view.ViewComponent
-import com.adealink.frame.mvvm.viewmodel.viewModels
+import com.adealink.frame.mvvm.viewmodel.activityViewModels
 import com.adealink.weparty.commonui.ext.gone
 import com.adealink.weparty.commonui.ext.show
+import com.adealink.weparty.commonui.toast.util.showFailedToast
 import com.adealink.weparty.im.databinding.LayoutSessionBottomBarBinding
-import com.adealink.weparty.im.session.comp.viewmodel.SessionBottomStyle
-import com.adealink.weparty.im.session.comp.viewmodel.SessionBottomViewModel
-import com.tencent.qcloud.tuikit.tuichat.bean.C2CChatInfo
-import com.tencent.qcloud.tuikit.tuichat.presenter.C2CChatPresenter
+import com.adealink.weparty.im.session.comp.input.InputAction
+import com.adealink.weparty.im.session.comp.input.InputState
+import com.adealink.weparty.im.session.comp.viewmodel.SessionInputViewModel
+import com.tencent.qcloud.tuikit.tuichat.bean.ChatInfo
+import com.tencent.qcloud.tuikit.tuichat.presenter.ChatPresenter
 
 class SessionBottomComp(
     lifecycleOwner: LifecycleOwner,
     val bottomBar: LayoutSessionBottomBarBinding,
-    val chatInfo: C2CChatInfo? = null,
-    val presenter: C2CChatPresenter? = null
+    val chatInfo: ChatInfo? = null,
+    val presenter: ChatPresenter? = null
 ) : ViewComponent(lifecycleOwner) {
 
-    private val bottomBarViewModel by viewModels<SessionBottomViewModel>()
+    private val inputViewModel by activityViewModels<SessionInputViewModel>()
 
     override fun onCreate() {
         super.onCreate()
@@ -28,30 +30,36 @@ class SessionBottomComp(
     }
 
     private fun initView() {
-        bottomBar.vVoiceBar.root.gone()
-        bottomBar.vInputBar.root.show()
-        bottomBarViewModel.changeStyle(SessionBottomStyle.INPUT)
+        inputViewModel.execute(InputAction.EMPTY_CLICKED)
     }
 
     private fun initComponent() {
         SessionBottomInputComp(lifecycleOwner, bottomBar.vInputBar, chatInfo, presenter).attach()
-        SessionBottomVoiceComp(lifecycleOwner, bottomBar.vVoiceBar, chatInfo, presenter).attach()
+        SessionBottomAudioComp(lifecycleOwner, bottomBar.vVoiceBar, chatInfo, presenter).attach()
     }
 
     private fun observeViewModel() {
-        bottomBarViewModel.styleLD.observe(viewLifecycleOwner) { style ->
-            when (style) {
-                SessionBottomStyle.INPUT -> {
+        inputViewModel.inputStateLD.observe(viewLifecycleOwner) { state ->
+            when (state.currentState) {
+                InputState.STATE_NONE,
+                InputState.STATE_SOFT_INPUT,
+                InputState.STATE_EMOJI_INPUT,
+                InputState.STATE_IMAGE_INPUT -> {
                     bottomBar.vVoiceBar.root.gone()
                     bottomBar.vInputBar.root.show()
                 }
 
-                SessionBottomStyle.VOICE -> {
+                InputState.STATE_AUDIO_INPUT -> {
                     bottomBar.vVoiceBar.root.show()
                     bottomBar.vInputBar.root.gone()
                 }
             }
         }
+
+        inputViewModel.sendMessageResultLD.observe(viewLifecycleOwner) { result ->
+            showFailedToast(result)
+        }
+
     }
 
 }

+ 60 - 9
module/im/src/main/java/com/adealink/weparty/im/session/comp/SessionBottomInputComp.kt

@@ -1,26 +1,30 @@
 package com.adealink.weparty.im.session.comp
 
+import android.text.Editable
+import android.text.TextWatcher
 import androidx.lifecycle.LifecycleOwner
 import com.adealink.frame.data.json.froJsonErrorNull
 import com.adealink.frame.log.Log
 import com.adealink.frame.mvvm.view.ViewComponent
-import com.adealink.frame.mvvm.viewmodel.viewModels
+import com.adealink.frame.mvvm.viewmodel.activityViewModels
 import com.adealink.frame.util.onClick
+import com.adealink.weparty.im.R
 import com.adealink.weparty.im.databinding.LayoutSessionBottomInputBarBinding
-import com.adealink.weparty.im.session.comp.viewmodel.SessionBottomStyle
-import com.adealink.weparty.im.session.comp.viewmodel.SessionBottomViewModel
+import com.adealink.weparty.im.session.comp.input.InputAction
+import com.adealink.weparty.im.session.comp.viewmodel.SessionInputViewModel
 import com.adealink.weparty.module.im.data.TAG_IM_SESSION
-import com.tencent.qcloud.tuikit.tuichat.bean.C2CChatInfo
-import com.tencent.qcloud.tuikit.tuichat.presenter.C2CChatPresenter
+import com.tencent.qcloud.tuikit.tuichat.bean.ChatInfo
+import com.tencent.qcloud.tuikit.tuichat.presenter.ChatPresenter
 
 class SessionBottomInputComp(
     lifecycleOwner: LifecycleOwner,
     val inputBar: LayoutSessionBottomInputBarBinding,
-    val chatInfo: C2CChatInfo? = null,
-    val presenter: C2CChatPresenter? = null
+    val chatInfo: ChatInfo? = null,
+    val presenter: ChatPresenter? = null
 ) : ViewComponent(lifecycleOwner) {
 
-    private val bottomBarViewModel by viewModels<SessionBottomViewModel>()
+    private val bottomBarViewModel by activityViewModels<SessionInputViewModel>()
+    private var isTextInput = false
     override fun onCreate() {
         super.onCreate()
         initView()
@@ -66,8 +70,50 @@ class SessionBottomInputComp(
         }
         inputBar.etInputMessage.setText(inputContent)
         inputBar.etInputMessage.setSelection(inputBar.etInputMessage.getText()?.length ?: 0)
+        isTextInput = !inputBar.etInputMessage.text.isNullOrEmpty()
+        setTextInputMode()
+
+        inputBar.etInputMessage.addTextChangedListener(object : TextWatcher {
+            override fun beforeTextChanged(
+                s: CharSequence?,
+                start: Int,
+                count: Int,
+                after: Int
+            ) {
+                inputContent = s?.toString()
+            }
+
+            override fun onTextChanged(
+                s: CharSequence?,
+                start: Int,
+                before: Int,
+                count: Int
+            ) {
+            }
+
+            override fun afterTextChanged(s: Editable?) {
+                val input = s?.toString()?.trim()
+                isTextInput = !input.isNullOrEmpty()
+                setTextInputMode()
+            }
+        })
     }
 
+    private fun setTextInputMode() {
+        if (isTextInput) {
+            inputBar.btnVoice.setImageResource(R.drawable.im_session_message_send_ic)
+            inputBar.btnVoice.onClick {
+                clickSendMessage()
+            }
+        } else {
+            inputBar.btnVoice.setImageResource(R.drawable.im_session_voice_ic)
+            inputBar.btnVoice.onClick {
+                clickVoice()
+            }
+        }
+    }
+
+
     fun saveDraft() {
         if (chatInfo == null) {
             Log.e(TAG_IM_SESSION, "set drafts error :  chatInfo is null")
@@ -98,8 +144,13 @@ class SessionBottomInputComp(
 
     }
 
+    private fun clickSendMessage() {
+
+    }
+
     private fun clickVoice() {
-        bottomBarViewModel.changeStyle(SessionBottomStyle.VOICE)
+        bottomBarViewModel.execute(InputAction.CLICK_AUDIO)
     }
 
+
 }

+ 0 - 63
module/im/src/main/java/com/adealink/weparty/im/session/comp/SessionBottomVoiceComp.kt

@@ -1,63 +0,0 @@
-package com.adealink.weparty.im.session.comp
-
-import androidx.lifecycle.LifecycleOwner
-import com.adealink.frame.mvvm.view.ViewComponent
-import com.adealink.frame.mvvm.viewmodel.viewModels
-import com.adealink.frame.util.onClick
-import com.adealink.weparty.im.R
-import com.adealink.weparty.im.databinding.LayoutSessionBottomVoiceBarBinding
-import com.adealink.weparty.im.session.comp.viewmodel.SessionBottomStyle
-import com.adealink.weparty.im.session.comp.viewmodel.SessionBottomViewModel
-import com.tencent.qcloud.tuikit.tuichat.bean.C2CChatInfo
-import com.tencent.qcloud.tuikit.tuichat.presenter.C2CChatPresenter
-
-class SessionBottomVoiceComp(
-    lifecycleOwner: LifecycleOwner,
-    val voiceBar: LayoutSessionBottomVoiceBarBinding,
-    val chatInfo: C2CChatInfo? = null,
-    val presenter: C2CChatPresenter? = null
-) : ViewComponent(lifecycleOwner) {
-
-    private val bottomBarViewModel by viewModels<SessionBottomViewModel>()
-
-    private var isPause = false
-    override fun onCreate() {
-        super.onCreate()
-        initView()
-        observeViewModel()
-    }
-
-
-    private fun initView() {
-        voiceBar.btnRemove.onClick {
-            clickDelete()
-        }
-        voiceBar.btnPause.onClick {
-            clickPause()
-        }
-        voiceBar.btnSend.onClick {
-            clickSend()
-        }
-    }
-
-    private fun observeViewModel() {
-
-    }
-
-    private fun clickDelete() {
-        bottomBarViewModel.changeStyle(SessionBottomStyle.INPUT)
-    }
-
-    private fun clickPause() {
-        isPause = !isPause
-        if (isPause) {
-            voiceBar.btnPause.setImageResource(R.drawable.im_session_voice_record_resume_ic)
-        } else {
-            voiceBar.btnPause.setImageResource(R.drawable.im_session_voice_record_pause_ic)
-        }
-    }
-
-    private fun clickSend() {
-        bottomBarViewModel.changeStyle(SessionBottomStyle.INPUT)
-    }
-}

+ 122 - 0
module/im/src/main/java/com/adealink/weparty/im/session/comp/input/SessionInputMachine.kt

@@ -0,0 +1,122 @@
+package com.adealink.weparty.im.session.comp.input
+
+import com.adealink.frame.log.Log
+import com.adealink.frame.mvvm.livedata.ExtMutableLiveData
+import com.adealink.frame.util.isMainThread
+import com.adealink.weparty.module.im.data.TAG_IM_INPUT_FLOW
+
+
+//输入行为
+enum class InputAction {
+    //    MORE_CLICKED,//点击更多
+    CLICK_INPUT, //点击输入框
+    CLICK_EMOTION_BUTTON, //点击表情按钮
+    CLICK_AUDIO, //点击录音
+    CANCEL_AUDIO, //取消录音
+
+    CLICK_IMAGE,
+    EMPTY_CLICKED//空白状态
+
+}
+
+//输入状态
+enum class InputState {
+    STATE_NONE,
+
+    //    STATE_MORE_INPUT,
+    STATE_SOFT_INPUT,
+    STATE_EMOJI_INPUT,
+    STATE_AUDIO_INPUT,
+    STATE_IMAGE_INPUT,
+}
+
+class SessionInputMachine(
+    private var currentState: InputState,
+    private val transactionList: MutableList<InputMachineTransaction>,
+
+    private val onTransaction: (transaction: InputMachineTransaction) -> Unit,
+    private val onStateChanged: (lastState: InputState, state: InputState) -> Unit
+) {
+
+    fun getCurrentState(): InputState {
+        return currentState
+    }
+
+    fun registerTransaction(transaction: InputMachineTransaction) {
+        transactionList.add(transaction)
+    }
+
+    fun execute(action: InputAction) {
+        var nextState: InputState? = null
+        for (transaction in transactionList) {
+            if (transaction.currentState == currentState && transaction.action == action) {
+                Log.d(
+                    TAG_IM_INPUT_FLOW,
+                    "SessionInputMachine, action:$action, $currentState => ${transaction.nextState}"
+                )
+                transaction.runEvent()
+                if (nextState == null) {
+                    nextState = transaction.nextState
+                } else if (nextState != transaction.nextState) {
+                    throw IllegalStateException("InputMachineTransaction define error, action:$action so marny nextState($nextState, ${transaction.nextState})")
+                }
+            }
+        }
+
+        if (nextState != null) {
+            notifyStateChanged(nextState)
+        }
+    }
+
+    private fun notifyStateChanged(nextState: InputState) {
+        val lastState = currentState
+        currentState = nextState
+        onStateChanged.invoke(lastState, nextState)
+    }
+
+    fun clean() {
+        currentState = InputState.STATE_NONE
+    }
+}
+
+data class InputMachineTransaction(
+    val currentState: InputState,
+    val action: InputAction,
+    val nextState: InputState,
+) {
+    val event = ExtMutableLiveData<Unit>()
+
+    init {
+        if (currentState == InputState.STATE_NONE && nextState == InputState.STATE_NONE) {
+            //重置操作
+        } else if (currentState == nextState) {
+            throw IllegalStateException("InputMachineTransaction define error, currentState(${currentState}) == nextState(${nextState})")
+        }
+    }
+
+    fun runEvent() {
+        if (isMainThread()) {
+            event.setValue(Unit)
+        } else {
+            event.postValue(Unit)
+            event.postValue(Unit)
+        }
+    }
+
+    override fun hashCode(): Int {
+        return "${currentState.ordinal}_${action.ordinal}_${nextState.ordinal}".hashCode()
+    }
+
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        if (javaClass != other?.javaClass) return false
+
+        other as InputMachineTransaction
+
+        if (currentState != other.currentState) return false
+        if (action != other.action) return false
+        if (nextState != other.nextState) return false
+
+        return true
+    }
+}

+ 0 - 19
module/im/src/main/java/com/adealink/weparty/im/session/comp/viewmodel/SessionBottomViewModel.kt

@@ -1,19 +0,0 @@
-package com.adealink.weparty.im.session.comp.viewmodel
-
-import androidx.lifecycle.MutableLiveData
-import com.adealink.frame.mvvm.viewmodel.BaseViewModel
-
-enum class SessionBottomStyle {
-    INPUT,
-    VOICE
-}
-
-class SessionBottomViewModel : BaseViewModel() {
-
-    val styleLD = MutableLiveData<SessionBottomStyle>()
-
-    fun changeStyle(style: SessionBottomStyle) {
-        styleLD.send(style)
-    }
-
-}

+ 223 - 0
module/im/src/main/java/com/adealink/weparty/im/session/comp/viewmodel/SessionInputViewModel.kt

@@ -0,0 +1,223 @@
+package com.adealink.weparty.im.session.comp.viewmodel
+
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import com.adealink.frame.base.IError
+import com.adealink.frame.base.Rlt
+import com.adealink.frame.mvvm.livedata.ExtMutableLiveData
+import com.adealink.frame.mvvm.livedata.OnceMutableLiveData
+import com.adealink.frame.mvvm.viewmodel.BaseViewModel
+import com.adealink.weparty.im.session.comp.input.InputAction
+import com.adealink.weparty.im.session.comp.input.InputMachineTransaction
+import com.adealink.weparty.im.session.comp.input.InputState
+import com.adealink.weparty.im.session.comp.input.SessionInputMachine
+import com.tencent.qcloud.tuikit.timcommon.bean.TUIMessageBean
+import com.tencent.qcloud.tuikit.timcommon.component.interfaces.IUIKitCallback
+import com.tencent.qcloud.tuikit.tuichat.bean.ChatInfo
+import com.tencent.qcloud.tuikit.tuichat.component.progress.ProgressPresenter
+import com.tencent.qcloud.tuikit.tuichat.presenter.ChatPresenter
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.suspendCancellableCoroutine
+
+
+data class SessionInputState(
+    val lastState: InputState,
+    val currentState: InputState
+)
+
+class SessionInputViewModel : BaseViewModel() {
+
+    val transactionLD = ExtMutableLiveData<InputMachineTransaction>()
+    val inputStateLD = MutableLiveData<SessionInputState>()
+    private val stateMachine: SessionInputMachine =
+        SessionInputMachine(
+            InputState.STATE_NONE,
+            mutableListOf(
+                /**
+                 * transition to [InputState.STATE_EMOJI_INPUT]
+                 */
+                InputMachineTransaction(
+                    currentState = InputState.STATE_NONE,
+                    action = InputAction.CLICK_EMOTION_BUTTON,
+                    nextState = InputState.STATE_EMOJI_INPUT
+                ),
+                InputMachineTransaction(
+                    currentState = InputState.STATE_SOFT_INPUT,
+                    action = InputAction.CLICK_EMOTION_BUTTON,
+                    nextState = InputState.STATE_EMOJI_INPUT
+                ),
+
+                /**
+                 * transition to [InputState.STATE_SOFT_INPUT]
+                 */
+                InputMachineTransaction(
+                    currentState = InputState.STATE_EMOJI_INPUT,
+                    action = InputAction.CLICK_INPUT,
+                    nextState = InputState.STATE_SOFT_INPUT
+                ),
+                InputMachineTransaction(
+                    currentState = InputState.STATE_EMOJI_INPUT,
+                    action = InputAction.CLICK_EMOTION_BUTTON,
+                    nextState = InputState.STATE_SOFT_INPUT
+                ),
+                InputMachineTransaction(
+                    currentState = InputState.STATE_NONE,
+                    action = InputAction.CLICK_INPUT,
+                    nextState = InputState.STATE_SOFT_INPUT
+                ),
+
+                /**
+                 * transition to [InputState.STATE_NONE]
+                 */
+                InputMachineTransaction(
+                    currentState = InputState.STATE_NONE,
+                    action = InputAction.EMPTY_CLICKED,
+                    nextState = InputState.STATE_NONE
+                ),
+                InputMachineTransaction(
+                    currentState = InputState.STATE_EMOJI_INPUT,
+                    action = InputAction.EMPTY_CLICKED,
+                    nextState = InputState.STATE_NONE
+                ),
+                InputMachineTransaction(
+                    currentState = InputState.STATE_SOFT_INPUT,
+                    action = InputAction.EMPTY_CLICKED,
+                    nextState = InputState.STATE_NONE
+                ),
+                InputMachineTransaction(
+                    currentState = InputState.STATE_EMOJI_INPUT,
+                    action = InputAction.CLICK_AUDIO,
+                    nextState = InputState.STATE_NONE
+                ),
+                InputMachineTransaction(
+                    currentState = InputState.STATE_AUDIO_INPUT,
+                    action = InputAction.CANCEL_AUDIO,
+                    nextState = InputState.STATE_NONE
+                ),
+
+                /**
+                 * transition to [InputState.STATE_AUDIO_INPUT]
+                 */
+                InputMachineTransaction(
+                    currentState = InputState.STATE_SOFT_INPUT,
+                    action = InputAction.CLICK_AUDIO,
+                    nextState = InputState.STATE_AUDIO_INPUT
+                ),
+
+                InputMachineTransaction(
+                    currentState = InputState.STATE_EMOJI_INPUT,
+                    action = InputAction.CLICK_AUDIO,
+                    nextState = InputState.STATE_AUDIO_INPUT
+                ),
+                InputMachineTransaction(
+                    currentState = InputState.STATE_NONE,
+                    action = InputAction.CLICK_AUDIO,
+                    nextState = InputState.STATE_AUDIO_INPUT
+                ),
+            ),
+            { transaction ->
+                transactionLD.send(transaction)
+            },
+            { last, current ->
+                inputStateLD.send(SessionInputState(last, current))
+            }
+        )
+
+
+    /**
+     * 参考:InputAction
+     */
+    fun execute(action: InputAction) {
+        stateMachine.execute(action)
+    }
+
+    fun registerTransaction(transaction: InputMachineTransaction) {
+        stateMachine.registerTransaction(transaction)
+    }
+
+
+    private var chatInfo: ChatInfo? = null
+    private var presenter: ChatPresenter? = null
+
+    val sendMessageResultLD = ExtMutableLiveData<Rlt<TUIMessageBean?>>()
+
+    fun init(
+        chatInfo: ChatInfo? = null,
+        presenter: ChatPresenter? = null
+    ) {
+        this.chatInfo = chatInfo
+        this.presenter = presenter
+    }
+
+    fun sendMessage(message: TUIMessageBean) {
+        sendMessage(message, false, null)
+    }
+
+    fun sendMessage(
+        message: TUIMessageBean,
+        retry: Boolean,
+        callback: IUIKitCallback<TUIMessageBean>?
+    ): LiveData<String?> {
+        val liveData = OnceMutableLiveData<String>()
+        viewModelScope.launch {
+            val presenter = presenter
+            if (presenter == null) {
+                liveData.send(null)
+                return@launch
+            }
+            val msgId = sendMessage(presenter, message, retry, callback)
+            liveData.send(msgId)
+        }
+        return liveData
+    }
+
+    private suspend fun sendMessage(
+        presenter: ChatPresenter,
+        message: TUIMessageBean,
+        retry: Boolean,
+        callback: IUIKitCallback<TUIMessageBean>?
+    ): String? {
+        return suspendCancellableCoroutine { continuation ->
+            presenter.sendMessage(
+                message,
+                retry,
+                false,
+                object : IUIKitCallback<TUIMessageBean?>() {
+                    override fun onSuccess(data: TUIMessageBean?) {
+                        callback?.onSuccess(data)
+                        sendMessageResultLD.send(Rlt.Success(data))
+                    }
+
+                    override fun onError(module: String?, errCode: Int, errMsg: String?) {
+                        callback?.onError(errCode, errMsg, message)
+                        sendMessageResultLD.send(
+                            Rlt.Failed(
+                                IError(
+                                    serverCode = errCode,
+                                    msg = errMsg ?: ""
+                                )
+                            )
+                        )
+//                        var toastMsg = errMsg
+//                        if (errCode == TUIConstants.BuyingFeature.ERR_SDK_INTERFACE_NOT_SUPPORT) {
+//                            showNotSupportDialog()
+//                            if (msg.isNeedReadReceipt()) {
+//                                toastMsg =
+//                                    (getResources().getString(R.string.chat_message_read_receipt)
+//                                            + getResources().getString(R.string.TUIKitErrorUnsupporInterfaceSuffix))
+//                            }
+//                        }
+//                        ToastUtil.toastLongMessage(toastMsg)
+                    }
+
+                    override fun onProgress(data: Any?) {
+                        val progress = (data as? Int) ?: 0
+                        callback?.onProgress(progress)
+                        ProgressPresenter.updateProgress(message.id, progress)
+                    }
+                })
+        }
+    }
+
+
+}

+ 0 - 0
module/im/src/main/res/drawable-xhdpi/im_session_voice_record_send_ic.png → module/im/src/main/res/drawable-xhdpi/im_session_message_send_ic.png


+ 1 - 1
module/im/src/main/res/layout/layout_session_bottom_voice_bar.xml

@@ -68,7 +68,7 @@
             app:layout_constraintBottom_toBottomOf="@id/cl_op"
             app:layout_constraintEnd_toEndOf="parent"
             app:layout_constraintTop_toTopOf="@id/cl_op"
-            app:srcCompat="@drawable/im_session_voice_record_send_ic" />
+            app:srcCompat="@drawable/im_session_message_send_ic" />
 
     </androidx.constraintlayout.widget.ConstraintLayout>
 

+ 3 - 0
module/im/src/main/res/values/strings.xml

@@ -7,4 +7,7 @@
     <string name="im_time_just_now">刚刚</string>
     <string name="im_session_input_message">Please enter text</string>
     <string name="im_no_suport_message">Unsupported messages</string>
+    <string name="im_audio_say_time_short">Message too short</string>
+    <string name="im_mic_is_being_used_cant_record">Microphone is being used by another function, unable to record.</string>
+    <string name="im_record_audio_failed">Audio recording failed.</string>
 </resources>